Goroutine Stacks

Every goroutine owns a private, contiguous, growable stack. Unlike an operating-system thread, whose stack is a fixed multi-megabyte region reserved at thread creation, a goroutine begins life with a tiny stack — 2 KiB by default since Go 1.4 — and grows it on demand by copying the entire stack into a larger allocation. This single design choice is what makes it cheap to have millions of goroutines: the runtime never pre-commits more stack than a goroutine actually uses, and it reclaims slack by shrinking idle stacks during garbage collection (src/runtime/stack.go). The mechanism — a compiler-inserted prologue check, a morestack/newstack trap, and a precise pointer-rewriting copystack — is invisible from Go source but explains nearly every “stack overflow” panic and a good deal of Go’s allocation behavior.

Mental Model

Think of a goroutine stack as a right-sized, relocatable buffer, not a fixed address range. The compiler emits a few instructions at the top of (almost) every function — the prologue — that compare the current stack pointer against a per-goroutine guard value. If the function’s frame would not fit, control traps into the runtime, which allocates a bigger stack, physically copies the old stack’s bytes into it, rewrites every pointer that referred into the old stack, and resumes the function as if nothing happened. Because the stack can move, no long-lived pointer into a stack is safe across a growth event — which is precisely why the garbage collector, the runtime, and cgo all have rules about stack pointers.

flowchart TD
    A["Function entry: prologue<br/>compare SP vs g.stackguard0"] -->|"SP above guard:<br/>frame fits"| B[Run function body normally]
    A -->|"SP below guard:<br/>frame would overflow"| C["morestack (assembly)"]
    C --> D["newstack (Go)"]
    D --> E{"guard == stackPreempt?"}
    E -->|yes| F["Preemption path:<br/>gopreempt_m / preemptPark"]
    E -->|no| G["oldsize = hi - lo<br/>newsize = oldsize * 2<br/>(double until frame fits)"]
    G --> H["casgstatus -> _Gcopystack"]
    H --> I["copystack:<br/>allocate new stack,<br/>memmove bytes,<br/>adjust all pointers"]
    I --> J["casgstatus -> _Grunning<br/>gogo: resume function"]

Figure: the stack-growth decision path. The key insight is that the same prologue check serves two unrelated jobs — detecting genuine stack overflow and detecting a preemption request — because both are signalled by poisoning the same stackguard0 field. A real growth doubles the stack and copies; a preemption request (stackguard0 == stackPreempt) diverts into the scheduler instead.

Why Goroutine Stacks Are Not Thread Stacks

A POSIX thread created with default attributes gets a stack of 8 MiB of virtual address space on Linux (ulimit -s), of which only touched pages are resident. That is acceptable when you have dozens of threads; it is fatal when you want a million concurrent units of execution, because address space, page-table entries, and guard pages all add up. Go’s answer is to make the stack a runtime-managed object the same way the heap is. A goroutine’s stack starts at the value of the runtime variable startingStackSize (var startingStackSize uint32 = fixedStack), which is itself derived from the constant stackMin = 2048 bytes (stack.go). fixedStack rounds stackMin + stackSystem up to a power of two, where stackSystem is a small per-OS reservation for signal handling and OS bookkeeping. The reservation is platform-conditional: in the go1.26.3 source it is stackSystem = goos.IsWindows*4096 + goos.IsPlan9*512 + goos.IsIos*goarch.IsArm64*1024, so on the common 64-bit Unix platforms — linux/amd64, linux/arm64, darwin/amd64stackSystem is 0 and fixedStack resolves to exactly stackMin = 2048 bytes, i.e. 2 KiB. On Windows it is (2048 + 4096) rounded up to 8 KiB, on Plan 9 (2048 + 512) rounded up to 4 KiB, and on ios/arm64 (2048 + 1024) rounded up to 4 KiB — so “2 KiB” is precisely the mainstream-Unix default, not a universal constant.

The value 2 KiB is itself a historical artefact. Go 1.2 raised the minimum goroutine stack from 4 KiB to 8 KiB to reduce the cost of segmented stacks (see below). Go 1.4 introduced contiguous stacks and, freed of the segmented-stack penalty, lowered the default back down to 2 KiB — the Go 1.4 release notes say so explicitly: “the default starting size for a goroutine’s stack in 1.4 has been reduced from 8192 bytes to 2048 bytes” (Go 1.4 release notes). A common interview-era claim that the initial size is “8 KB” reflects the Go 1.2–1.3 era and has been wrong since late 2014.

From Segmented Stacks to Contiguous Stacks

Go’s first stack-growth design (pre-1.4) used segmented stacks: when a function ran out of room, the runtime allocated a new, separate stack segment and linked it to the old one, so a goroutine’s stack became a linked list of chunks. The fatal flaw was the hot split (or “hot stack split”) problem. If a tight loop repeatedly called a function whose frame straddled a segment boundary, every iteration would allocate a new segment on entry and free it on return — turning a cheap call into a pair of allocations. Performance became wildly unpredictable: a one-line code change could move a call across a boundary and cause a 10× slowdown.

Go 1.4 replaced this with contiguous stacks. The Go 1.4 notes summarise it precisely: “stacks are no longer segmented… When a stack limit is reached, a new, larger stack is allocated, all active frames for the goroutine are copied there, and any pointers into the stack are updated. Performance can be noticeably better in some cases and is always more predictable” (Go 1.4 release notes). Because growth is multiplicative (the stack doubles each time), the amortised cost of all the copying a goroutine ever incurs is O(final stack size) — the same complexity as a []byte that doubles on append. The hot-split problem vanishes entirely: a function near a boundary either fits or triggers exactly one growth, after which it always fits.

Mechanical Walk-through

The prologue check

For every function whose frame is non-trivial, the gc compiler emits a prologue that compares the stack pointer against g.stackguard0, a field of the running goroutine’s g struct. The guard is positioned a fixed distance — stackGuard bytes — above the true bottom of the stack (g.stack.lo). The comment in stack.go explains the budgeting: “The guard leaves enough room for a stackNosplit chain of NOSPLIT calls plus one stackSmall frame plus stackSystem bytes for the OS.” That headroom exists so that runtime functions marked //go:nosplit — functions that must not trigger growth, because growth itself calls functions — can run safely even when the stack is nearly exhausted.

If the prologue determines the frame fits, it falls straight through into the function body with zero further cost. If not, it calls the assembly routine morestack, which saves the goroutine’s register state into g.sched and switches to the M’s system stack (g0) to run newstack.

newstack: the growth decision

newstack (stack.go) runs on the system stack and first reads g.stackguard0 once with an atomic load — it can change underfoot if another thread is simultaneously trying to preempt this goroutine. Two cases follow:

  1. Preemption. If stackguard0 == stackPreempt, this was not a real overflow at all; another thread (typically the scheduler or the garbage collector) poisoned the guard to force this goroutine into the runtime at the next function call. newstack checks canPreemptM and, if preemption is allowed, diverts into gopreempt_m (yield) or preemptPark (stop for the GC). This is cooperative preemption — see Goroutine Preemption. If preemption is not currently safe (locks held, in malloc, etc.), newstack restores the real guard and lets the goroutine keep running; the preempt flag stays set so the next call retries.

  2. Genuine growth. Otherwise the stack is truly too small. newstack computes oldsize = gp.stack.hi - gp.stack.lo and newsize = oldsize * 2. It then inspects the function that triggered the trap (funcMaxSPDelta) to learn that function’s maximum frame size and keeps doubling newsize until the new stack is provably large enough: for newsize-used < needed { newsize *= 2 } (here needed := max + stackGuard and used := gp.stack.hi - gp.sched.sp). If newsize would exceed maxstacksize the runtime throws a real "stack overflow" — the panic everyone has seen from infinite recursion. runtime.main sets maxstacksize to 1,000,000,000 bytes (≈0.93 GiB) on 64-bit platforms and 250,000,000 (≈238 MiB) on 32-bit (if goarch.PtrSize == 8 { maxstacksize = 1000000000 } else { maxstacksize = 250000000 } in proc.go); note these are decimal limits, not powers of two, so “1 GiB” is a common rounding. A separate hard ceiling maxstackceiling = 2 * maxstacksize caps the stack even when the limit is raised via debug.SetMaxStack.

copystack: relocating the stack

With a target size chosen, newstack transitions the goroutine to the _Gcopystack status (casgstatus(gp, _Grunning, _Gcopystack)) and calls copystack. The status change matters: while a goroutine is _Gcopystack, the concurrent garbage collector knows not to scan its stack, because the stack is mid-flight and its pointers are momentarily inconsistent.

copystack performs four steps:

  1. Allocate a new stack of newsize bytes via stackalloc, which itself draws from per-P stack caches (stackcache) for small sizes and from stackLarge/mheap for big ones — so growth usually does not even take a global lock.
  2. memmove the live portion of the old stack into the new one.
  3. Adjust pointers. This is the subtle part. Every pointer that pointed into the old stack range must be rewritten by the constant offset newbase - oldbase. The runtime walks each stack frame using the compiler-generated stack maps (which say exactly which slots are pointers), and also adjusts saved registers in g.sched, the defer chain, panic records, and sudog structures the goroutine is parked on. Because the stack maps are precise, the runtime knows unambiguously which words are pointers and which are integers that merely look like addresses.
  4. Free the old stack (or return it to the cache).

newstack then flips the status back to _Grunning and calls gogo(&gp.sched) to resume the function — which now re-runs its prologue, sees the frame fits, and proceeds.

Stack shrinking

Stacks that grew for a transient deep call would otherwise stay large forever. The runtime reclaims them in shrinkstack, called during garbage collection (and at synchronous preemption safe-points, via the preemptShrink flag). shrinkstack halves the stack — newsize := oldsize / 2 — but only under two guards. First, it never shrinks below fixedStack. Second, and the rule worth memorising: it shrinks only if the goroutine is using less than a quarter of its current stack. The code computes used := gp.stack.hi - gp.sched.sp + stackNosplit and bails out if used >= avail/4. The quarter threshold provides hysteresis: a goroutine hovering near half-full will not thrash between grow and shrink. Shrinking is itself a copystack into a smaller allocation, gated by isShrinkStackSafe — which forbids shrinking a goroutine that is in a syscall, at an asynchronous safe-point, or in the narrow window of parking on a channel, because in those states the runtime lacks precise pointer maps for the innermost frames.

Adaptive starting size

A subtle optimisation: new goroutines do not always start at 2 KiB. The variable startingStackSize is recomputed every GC cycle by gcComputeStartingStackSize, which tracks the average scanned stack size across all goroutines and starts new goroutines at (a power of two near) that average. The stack.go comment justifies it: “Starting at the average size uses at most 2x the space that an ideal algorithm would have used.” If a program’s goroutines all settle around 16 KiB, new goroutines are born at ~16 KiB and skip three growth/copy events.

Code: Observing Stack Growth

package main
 
import "fmt"
 
// deep recurses n times, forcing the stack to grow.
// Each frame holds a 1 KiB array so growth happens fast.
func deep(n int) int {
	var pad [1024]byte // keeps the frame large; defeats tail-call-style optimisation
	pad[0] = byte(n)
	if n == 0 {
		return int(pad[0])
	}
	return deep(n-1) + int(pad[0]) // the recursive call re-triggers the prologue check
}
 
func main() {
	fmt.Println(deep(2000)) // ~2 MiB of frames -> several doublings: 2K->4K->...->2M+
}

Line-by-line: var pad [1024]byte forces each frame to be at least 1 KiB so that recursion exhausts the 2 KiB starting stack within the first two or three calls. The recursive call on the last line is an ordinary function call, so the compiler emits a prologue for deep; each entry compares SP against the guard. The first few entries trap into newstack, which doubles the stack and copystacks. Run with GODEBUG=gctrace=1 and GODEBUG=allocfreetrace to see related activity; run with the environment variable below to watch growth directly:

# stackDebug must be raised at build time to print growth, but the panic path
# is observable without a rebuild — infinite recursion hits maxstacksize:
GODEBUG=schedtrace=1000 ./prog          # periodic scheduler dumps
GOTRACEBACK=crash ./prog                # full traceback incl. runtime frames on overflow
// debug.SetMaxStack changes maxstacksize at runtime (default 1e9 bytes, ~0.93 GiB, on 64-bit).
package main
 
import (
	"fmt"
	"runtime/debug"
)
 
func main() {
	debug.SetMaxStack(64 * 1024) // cap any single goroutine stack at 64 KiB
	defer func() {
		if r := recover(); r != nil { // a stack-overflow throw is NOT recoverable...
			fmt.Println("recovered:", r)
		}
	}()
	var f func(int)
	f = func(i int) { f(i + 1) } // infinite recursion
	f(0)
}

debug.SetMaxStack lowers maxstacksize, making the overflow happen sooner and with a bounded crash dump. Note the comment: a true stack-overflow is a runtime throw, not a panic, so the recover above will not catch it — the process terminates. SetMaxStack is mainly a defence-in-depth limit for servers running untrusted or recursion-prone code.

Failure Modes and Common Misunderstandings

“Goroutine stack exceeds N-byte limit / stack overflow.” Real symptom of unbounded recursion or an extremely deep call chain (e.g. a recursive descent over a pathological data structure). It is a fatal throw; recover does not help. Diagnose by reading the traceback for a repeating cycle of frames. The fix is algorithmic — convert recursion to iteration with an explicit heap-allocated stack, or add a depth bound.

“Stack copying is invisible, so it is free.” It is not. A goroutine that oscillates in stack depth pays repeated copystack costs. The doubling policy and the quarter-full shrink threshold bound this, but a hot path that triggers growth every iteration (a frame that straddles a doubling boundary, combined with deep-then-shallow churn) can show up as runtime time in pprof under runtime.morestack/runtime.copystack. The fix is usually to reduce frame size — large arrays/structs declared as locals are the usual culprit; consider heap-allocating them once and reusing.

“A pointer into a goroutine’s stack is stable.” False, and dangerous. Because copystack moves the stack, any address taken of a stack variable may be stale after a growth. Pure Go code is safe — the compiler’s escape analysis heap-allocates anything whose address outlives its frame, and the runtime rewrites all in-stack pointers during copy. The danger is at the cgo and unsafe boundary: a Go pointer passed to C, or an integer-disguised pointer, is not tracked, so a stack move silently invalidates it. This is the deep reason cgo has rules about not storing Go pointers in C memory, and why entersyscall is //go:nosplit — see System Calls and the Scheduler.

“The system stack grows too.” No. Every M has a g0 system stack used for runtime/scheduler code; it is a fixed allocation and cannot grow. Runtime functions that run on it must be careful, which is why so many are marked //go:nosplit (HACKING.md).

Alternatives and When They Apply

The two historical alternatives are segmented stacks (Go pre-1.4, Rust’s pre-1.0 libgreen) and large fixed stacks (every OS-thread model — pthread, std::thread, Java platform threads). Segmented stacks avoid copying but suffer the hot-split pathology and complicate stack walking; the industry has largely abandoned them. Fixed large stacks are simple and never copy — pointers into them never move, which is why C and C++ can take stack addresses freely — but they cap concurrency at thousands, not millions, and waste address space. Contiguous growable stacks are the modern answer for language runtimes that own their scheduler: Go uses them, and Java’s Project Loom virtual threads use a conceptually similar copy-on-grow approach. A language that cannot move pointers freely (because it exposes raw stack addresses, like C) cannot adopt this design — which is the deep reason Go’s runtime, not the OS, must own the stack.

Production Notes

In production, goroutine stacks rarely need tuning — the defaults are well chosen. The knobs that exist: debug.SetMaxStack to cap pathological recursion; GODEBUG=gcshrinkstackoff=1 to disable stack shrinking (occasionally used to diagnose whether shrink-related copystack is a cost, or to keep pointers stable while debugging). Memory accounting: a server with one million mostly-idle goroutines, each shrunk back to 2 KiB, holds ~2 GiB of stack — a real number that surprises operators who expected goroutines to be “free.” runtime.ReadMemStats reports total stack memory in StackInuse/StackSys. The execution tracer (The Go Execution Tracer) and pprof expose runtime.morestack time when growth is hot. The practical advice: keep frames small, avoid declaring large arrays as locals on hot paths, and remember that millions of goroutines cost real stack memory even when each individual stack is tiny.

See Also