skip to content

A String() method on *Node returns fmt.Sprintf("Node(%v)", n) for its own receiver. What happens at runtime?

level: seniorimportance: should knowfreq 36%

answer

  1. the method calls what calls the method
  2. no base case anywhere
  3. the stack grows until the runtime gives up
  4. not a panic, so recover is useless
  5. convert to a method-free defined type

basics

~20 s

It recurses forever: %v sees that *Node implements fmt.Stringer and calls String again. The goroutine's stack grows until it exceeds the runtime limit, and the process dies with fatal error: stack overflow, which recover cannot stop.

solid answer

~40 s

`%v` on an operand that satisfies `fmt.Stringer` calls `String()`, so formatting the receiver inside its own `String()` re-enters the method immediately. Each round adds frames for `String` and `fmt.Sprintf`; the runtime grows the goroutine's stack until it passes the maximum (1 GB on 64-bit by default) and then aborts with `fatal error: stack overflow`. It is not a panic, so fmt's internal recover — which normally turns a panicking `String()` into `%!v(PANIC=...)` — cannot catch it, and neither can yours. The stack trace shows a repeating `main.(*Node).String` / `fmt.Sprintf` pair, which is the tell. The fix is to format something that has no `String()` method: convert to a defined type with the same underlying struct, `(*node)(n)`, or print the fields explicitly. `go vet`'s printf analyzer flags this pattern.

code

go · 5 lines
go
type node Node // same fields, empty method set

func (n *Node) String() string {
	return fmt.Sprintf("Node%+v", *(*node)(n))
}

go deeper

for a junior

Remember the rule of thumb: never format the receiver itself inside String(), because %v will call String() again and the program will not survive it.

for a middle

Explain the loop precisely — %v asserts the operand against fmt.Stringer, calls String, which calls Sprintf — and give a fix, such as converting to a defined type that has no methods.

for a senior

Diagnose it from the artefact: recognise the repeating method/fmt frame pair in a stack overflow trace, know that this is a fatal error rather than a recoverable panic, and connect the crash to a log line added on a rare path.

for a principal

Decide how the team prevents whole-process crashes originating in observability code: vet in the pipeline, tests that exercise printed forms, and a review rule that a String or Error body is scrutinised like production logic.

## Why it recurses When you call `fmt.Sprintf("Node(%v)", n)`, fmt inspects the operand at runtime. If the dynamic type satisfies `fmt.Stringer` — has a `String() string` method — and the verb accepts a string (`%v`, `%s`, `%q`, `%x`, `%X`), fmt calls that method and formats what it returns. Inside `String()`, the receiver `n` is exactly such an operand. So `String` calls `Sprintf`, which calls `String`, which calls `Sprintf`, with no base case anywhere. The same happens with `%s` and with `%+v`: the `+` flag is a flag on the `v` verb, not a different verb, so it takes the same Stringer path. The one general verb that escapes is `%#v`, which uses the Go-syntax path and consults `fmt.GoStringer`, not `Stringer` — though relying on that is a trap of its own, since a reader will "fix" the verb later and reintroduce the loop. ## What the failure actually looks like Goroutine stacks in Go start small and grow by copying, so the recursion is not stopped by a fixed frame budget — it keeps going until the stack passes the runtime maximum, 1 GB on 64-bit platforms (adjustable with `runtime/debug.SetMaxStack`). Then the runtime gives up: ``` runtime: goroutine stack exceeds 1000000000-byte limit fatal error: stack overflow ``` This is a **fatal error**, not a panic. Deferred functions do not run, `recover()` is irrelevant, and the whole process dies — not just the offending goroutine. That distinction matters operationally: a panic inside one request handler can be recovered by middleware and turned into a 500, while this takes down every in-flight request on the process. It is also why fmt's own safety net does not help. fmt wraps calls to `String()` in a recover so that a panicking method produces `%!v(PANIC=String method: ...)` in the output instead of crashing the program; a stack overflow blows straight past it. ## Reading the trace The printed trace is enormous — thousands of frames — and the instinct is to scroll for a cause. There is no cause further down; the diagnosis is the *shape*. Look at the top few frames and find the repeating cycle: ``` main.(*Node).String(...) fmt.Sprintf(...) main.(*Node).String(...) fmt.Sprintf(...) ``` A two-frame cycle alternating between one of your methods and a `fmt` entry point is this bug and essentially nothing else. The other detail worth noting is the goroutine header: the crashing goroutine is often not the one doing interesting work, because the trigger is usually a *log statement* — someone added `log.Printf("processing %v", n)` on an error path that only fires under load, and a debug line becomes an availability incident. ## The fixes **Convert to a type without the method.** A defined type has its own (empty) method set, so the conversion strips `String` while keeping the fields: ```go type node Node // no methods func (n *Node) String() string { return fmt.Sprintf("Node%+v", *(*node)(n)) } ``` This keeps the automatic field-by-field rendering, which is the reason people wrote the recursive version in the first place. **Format the fields explicitly.** Usually better, because it gives you control over what a self-referencing structure prints: ```go func (n *Node) String() string { return fmt.Sprintf("Node(%s->%p)", n.name, n.next) } ``` Note the second hazard hiding here. Even a *correct* `String()` that formats a linked field with `%v` will walk the whole structure, and if the links form a cycle — a node whose `next` chain returns to it — you get a different infinite recursion with the same fatal ending. fmt does not detect cycles. Printing a pointer with `%p`, or an identifier field, breaks the walk. **Do not reach for recover.** Even setting aside that it cannot catch a stack overflow, a `String()` that needs a recover is a `String()` that is doing too much. ## Catching it before production - `go vet` includes a printf analyzer that reports a `String` method whose body formats its own receiver — it is one of the checks that runs automatically as part of `go test`, so an ordinary test run will surface it. - Add a test that formats the value: `fmt.Sprintf("%v", n)` in a unit test is enough, since the bug is deterministic and immediate. - In review, treat any `String()` or `Error()` body containing `%v` on the receiver as a defect on sight, including the shape `fmt.Sprintf("%v", *n)` where the receiver is dereferenced but the value type still carries the method.

  • Why can't a deferred recover() inside String stop this?
    Because stack exhaustion is a fatal runtime error, not a panic. The runtime prints `fatal error: stack overflow` and aborts the process without unwinding, so deferred functions never run and `recover()` is never reached. fmt's own recover, which turns a panicking `String()` into `%!v(PANIC=...)`, is bypassed for the same reason.
  • Does switching the verb from %v to %+v or %s avoid the recursion?
    No. `+` is a flag on the `v` verb, and `%s` is another string-accepting verb, so all three take the same `fmt.Stringer` path. Only `%#v` avoids it, because the Go-syntax path consults `fmt.GoStringer` instead — but relying on that is fragile, since the next reader may switch the verb back.
  • What would catch this before it reaches production?
    `go vet`'s printf analyzer reports a `String` method that formats its own receiver, and vet's core checks run as part of `go test`, so an ordinary test run flags it. Backstop that with one test asserting `fmt.Sprintf("%v", value)` equals the expected text — the bug is deterministic, so a single call is enough.
  • A correct String method formats a linked field with %v. What can still go wrong?
    If the links form a cycle — a node whose next chain comes back to it — fmt walks it forever, because fmt does no cycle detection. The failure looks identical. Print a pointer with `%p`, an id, or a bounded summary of the neighbours instead of recursing into them.

saying these in an interview costs you the question

  • Says fmt detects the cycle and prints an ellipsis
  • Thinks recover in the deferred function saves the process
  • Believes only the crashing goroutine dies
  • Suggests %+v or %s as the fix
  • Scrolls the trace for a root cause instead of reading the cycle