skip to content

What causes 'fatal error: stack overflow' in Go if goroutine stacks grow on demand?

level: middleimportance: should knowfreq 34%

answer

  1. stacks are not a fixed size here
  2. grown by copying, but not forever
  3. there is a per-goroutine ceiling
  4. look for a function that reaches itself

basics

~20 s

Goroutine stacks grow by copying to a larger stack, but only up to a ceiling - 1 GB on 64-bit by default. Unbounded recursion reaches that ceiling and the runtime aborts the whole process with 'fatal error: stack overflow'.

solid answer

~50 s

A goroutine's stack is not fixed. It starts at a couple of kilobytes and the runtime grows it as needed by allocating a larger stack and copying the frames across, which is what makes goroutines cheap enough to create by the thousand. What it will not do is grow without bound: `runtime/debug.SetMaxStack` sets the per-goroutine ceiling and the default is 1 GB on 64-bit systems, 250 MB on 32-bit. When a goroutine needs more than that, the runtime prints `runtime: goroutine stack exceeds 1000000000-byte limit` followed by `fatal error: stack overflow` and terminates the process - no unwinding, no deferred calls. The cause is essentially always unbounded recursion, and the version that catches people is accidental: a `String` method that formats its own receiver with `%v`, or a `MarshalJSON` that calls `json.Marshal` on the same type.

code

go · 9 lines
go
type Sample struct {
	Label string
	Value float64
}

// Recurses: %v on s calls String, which calls Sprintf, which calls String.
func (s Sample) String() string {
	return fmt.Sprintf("sample %v", s)
}

go deeper

for a junior

Know that goroutine stacks grow automatically but not forever, and that unbounded recursion ends the whole program with a stack overflow message rather than an error you can handle.

for a middle

Explain the growth-by-copying mechanism, why a ceiling has to exist, and the accidental cycles that cause it in practice - a String method formatting its own receiver being the canonical one.

for a senior

Show how you read a truncated stack-overflow dump to name the cycle and the triggering input, and why the fix is a depth bound or an iterative rewrite rather than a larger limit.

for a principal

Treat recursion depth over untrusted input as a policy question: which components are allowed to recurse on external data, what depth limits they must enforce, and how that is verified rather than left to each author.

## Growable stacks An operating-system thread gets a large, fixed stack reserved up front - commonly one to eight megabytes. That is why you cannot have a million threads. Go takes a different approach: each goroutine starts with a tiny stack of a couple of kilobytes, and the runtime grows it on demand. Every function entry carries a cheap check for whether there is room for the frame; when there is not, the runtime allocates a larger stack, copies the existing frames into it, fixes up the pointers that referred into the old stack, and continues. Stacks can shrink again during garbage collection when a goroutine's usage drops. This is the mechanism that makes goroutines cheap. It also means the intuition carried over from other languages - 'the stack is a fixed small region and deep recursion smashes it' - is wrong here in the details. ## But there is still a ceiling Growth is not unlimited. If it were, a single runaway recursion would consume every byte the machine has and take the whole box down rather than one process. So the runtime enforces a maximum stack size per goroutine. The default is 1 GB on 64-bit platforms and 250 MB on 32-bit, and `runtime/debug.SetMaxStack` changes it at run time, returning the previous value. When a goroutine asks to grow past that ceiling, you get two lines: ``` runtime: goroutine stack exceeds 1000000000-byte limit fatal error: stack overflow ``` That is a fatal runtime error, not a panic. Nothing unwinds and no deferred call runs, which makes sense: running a deferred call needs a stack frame, and the failure is precisely that there is no room for another frame. ## What actually causes it In real code, three shapes account for nearly all of it. **Plain unbounded recursion.** A recursive descent with a base case that is never reached, usually because the input contains a cycle the code assumed was a tree - a directory tree with a symlink loop, a graph walked as if it were acyclic, a parent pointer chain with a cycle in it. **Accidental self-recursion through an interface method.** The classic: a `String() string` method that formats its own receiver with `%v` or `%s`. `fmt` sees a type implementing `fmt.Stringer`, calls `String`, which calls `fmt.Sprintf` on the same value, which calls `String` again. The same trap exists for an `Error() string` method, and for a `MarshalJSON` that calls `json.Marshal` on the same type rather than on a distinct alias type. **Mutual recursion across a boundary.** Two packages that each delegate to the other for the case they do not handle, with no case actually terminating. ## Reading the dump A stack overflow dump would otherwise be millions of frames, so the runtime prints the innermost frames, notes that additional frames were elided, and prints the outermost ones. That is exactly what you need: - The repeating pattern near the top names the cycle. If you see `String` and `fmt.Sprintf` alternating, you have found it. - The frames at the bottom name the entry point that started the descent, which tells you which input triggered it. ## Should you raise the limit? Almost never. Raising `SetMaxStack` on a runaway recursion converts a crash into a slower crash that first consumes a gigabyte of memory, often taking the machine's other processes with it. The legitimate cases are narrow: a genuinely deep but bounded recursive descent over data whose depth you control and have measured. The better fixes: - **Bound the depth explicitly.** Pass a depth counter and return a real error when it is exceeded. A parser that recurses over untrusted input should always do this - unbounded input depth is otherwise a denial-of-service vector that costs the attacker nothing. - **Convert the recursion to a loop.** An explicit slice used as a work stack has no ceiling problem and lets you report progress and cancel. - **Break the accidental cycle.** For a `String` method, format the fields individually, or convert the receiver to a distinct named type that does not have the method: `type alias Sample; return fmt.Sprintf("%+v", alias(s))`. ## Distinguishing it from other aborts Stack overflow is exhaustion of one goroutine's stack. It is not the heap running out, which reports `fatal error: out of memory`, and it is not a nil dereference, which is an ordinary panic naming a runtime error. If the message says stack, look for recursion; if it says out of memory, look at allocation volume.

  • Can you raise the per-goroutine stack limit, and should you?
    runtime/debug.SetMaxStack changes it at run time and returns the previous value. It is occasionally right for a bounded but genuinely deep recursive descent. Usually it turns a fast crash into a slow one that eats a gigabyte first. Prefer bounding the depth explicitly or rewriting the recursion as a loop with your own work stack.
  • Why does the runtime treat exceeding the stack limit as fatal instead of unwinding the goroutine?
    Because unwinding and running deferred calls needs stack frames, and the failure is that there is no room for another frame. The runtime cannot reliably execute more user code in that state, so it prints the truncated traceback and stops the process.
  • How do you find the cycle in a dump that would otherwise be millions of frames?
    The runtime prints the innermost frames, notes that the middle was elided, and prints the outermost frames. The repeating pattern at the top names the cycle directly - two functions alternating is the usual signature - and the bottom frames identify the entry point and therefore the input that triggered it.
  • Why is unbounded recursion over untrusted input a security concern?
    Depth is controlled by the attacker and the cost to them is trivial: a deeply nested document is a few kilobytes to send and a fatal abort to serve. Any parser or walker that recurses over external input needs an explicit depth limit that returns an error rather than relying on the stack ceiling.

The stack is a desk that gets swapped for a bigger desk whenever you run out of room. Useful, until the paperwork is infinite - at some point there is no bigger desk and the office closes.

saying these in an interview costs you the question

  • Thinks Go stacks are fixed size and cannot grow
  • Believes goroutine stacks grow without any limit
  • Expects only the recursing goroutine to die
  • Raises the stack limit instead of finding the recursion
  • Confuses stack exhaustion with heap exhaustion