skip to content

Why does a String() method that formats its own receiver with fmt.Sprintf crash the program?

level: seniorimportance: should knowfreq 38%

answer

  1. the method and the formatter call each other
  2. no base case, so the stack keeps growing
  3. this ends in a fatal error, not a panic
  4. convert the receiver to something without methods
  5. a vet analyzer catches the pattern

basics

~20 s

Formatting the receiver with %v or %s makes fmt call String again, so the method recurses forever. The goroutine stack grows to the runtime limit and the process dies with a fatal stack overflow, which recover cannot catch.

solid answer

~50 s

`fmt` consults `fmt.Stringer` for `%v`, `%s`, `%q`, `%x` and `%X`. If `String()` formats its own receiver with one of those verbs, `fmt` calls `String()` again, which calls `fmt` again — unbounded mutual recursion. The goroutine's stack keeps growing until it reaches the runtime's maximum stack size, and the program dies with `fatal error: stack overflow` plus a trace in which the same handful of `String` and `fmt` frames repeat. This is a fatal runtime error, not a panic: `recover` cannot catch it, and `fmt`'s own protection — which turns a panicking `String` method into a `%!v(PANIC=...)` marker — does not help either, because nothing panics. The fix is to strip the method before formatting: convert the receiver to its underlying type, or to a local defined type that carries no methods. `go vet`'s printf analyzer flags this pattern, which is why it belongs in CI rather than in review.

code

go · 11 lines
go
type Status string

// Recurses: %s re-invokes String on the same value.
func (s Status) String() string {
	return fmt.Sprintf("%s (%d chars)", s, len(s))
}

// Fixed: the converted value has no String method.
func (s Status) String() string {
	return fmt.Sprintf("%s (%d chars)", string(s), len(s))
}

go deeper

for a junior

Remember that formatting the receiver with %v or %s inside String() calls String() again. Know the safe form: convert the receiver to its underlying type first, or build the text without fmt.

for a middle

Explain the cycle precisely — which verbs re-invoke Stringer, why a conversion breaks it, and why a defined type inherits none of the original type's methods.

for a senior

Read the crash: a repeating frame cycle and a fatal stack overflow, not a panic, so recover and fmt's own protection are both irrelevant. Say how you would prevent recurrence with vet in CI and a formatting test.

for a principal

Treat it as a class, not an incident: rendering methods can crash the process on an untested path, so the standard is vet in the pipeline and a formatting assertion for every type that defines String().

## The loop Consider a CLI that prints job records, with a string-backed status enum: ```go type Status string func (s Status) String() string { return fmt.Sprintf("%s (%d chars)", s, len(s)) } ``` This looks harmless. It is not. `fmt` handles `%s` by asking whether the operand implements `fmt.Stringer`; `Status` does, so it calls `String()`; that method calls `fmt.Sprintf` with the same operand; `fmt` asks the same question and gets the same answer. There is no base case and no depth limit. The same trap appears with `%v`, `%q`, `%x` and `%X`, and with `fmt.Sprint`, `fmt.Errorf`, `log.Printf` or anything else routed through `fmt`. It also appears one step removed: a `String()` that formats a *field* whose type is the same defined type, or that formats a struct containing the receiver, recurses just as surely. ## What the failure looks like Go goroutine stacks start small and grow on demand, so the recursion does not fail immediately — it consumes memory until it reaches the runtime's per-goroutine maximum stack size, which is 1 GB on 64-bit platforms by default (`runtime/debug.SetMaxStack` changes it). Then the runtime prints a line reporting that the goroutine stack exceeded the limit, followed by `fatal error: stack overflow` and a trace. The trace is huge and mostly useless-looking: the same two or three frames — your `String` method, `fmt`'s formatting internals — alternating, with the middle elided by the runtime. That repeating pattern *is* the diagnostic. When a stack trace shows a short cycle of frames repeating, you are looking at unbounded recursion, and the frame that belongs to your own package names the culprit. There is no need to reach for a profiler. ## Why nothing catches it Two protections that people expect to help do not. First, `fmt` guards itself against badly behaved methods: if a `String()` or `Error()` method panics while `fmt` is calling it, `fmt` recovers and prints a `%!v(PANIC=...)` marker in place of that operand, so one bad type cannot take down a log line. But infinite recursion does not panic — it exhausts the stack, and the runtime raises a *fatal error*, which unwinds nothing. Second, `recover` in your own deferred function is equally powerless. Fatal runtime errors — stack overflow, concurrent map writes, out of memory — are throws, not panics; deferred functions do not run and the process exits. Wrapping the print in a `defer func(){ recover() }()` changes nothing except adding noise. The practical consequence is that this defect is never a degraded log line. It is a crashed process, at whatever moment a value of that type first happens to be printed — which may be an error path that only runs in production. ## The fix Remove the method from the operand before formatting it. Two idiomatic ways: ```go // 1. Convert to the underlying type. func (s Status) String() string { return fmt.Sprintf("%s (%d chars)", string(s), len(s)) } // 2. Convert to a local defined type, which inherits no methods. type plainStatus Status func (s Status) String() string { return fmt.Sprintf("%s (%d chars)", plainStatus(s), len(s)) } ``` Both work because a conversion produces a value of a *different* type, and a defined type does not inherit the methods of the type it is defined from. Option 1 is the obvious choice for a scalar-shaped type. Option 2 is what you need when the receiver is a struct and you still want `%v`'s field-by-field rendering, without re-entering your own method. A third option is simply not to use `fmt` at all: `return "status:" + string(s)` allocates less and cannot recurse. ## Keeping it out of the codebase `go vet`'s printf analyzer detects a `String` method that passes its own receiver to a formatting call under a verb that would re-invoke it, and reports it as a recursive call. `go test` runs a subset of vet on the package under test by default, so the check often fires the first time anyone runs the tests — but only if a test compiles that package. Making `go vet ./...` a CI step is what actually guarantees it. As a reviewer, the rule is short enough to apply by eye: inside a `String()` method, the receiver must never appear as a `%v`/`%s`/`%q` operand. If you see it, ask for the conversion. And the cheapest regression test for any type that defines `String()` is a one-liner asserting `fmt.Sprint(v)` equals the expected text — it exercises the method through `fmt`, which is exactly the path that crashes.

  • Why does recover() not save the program here?
    A stack overflow is a fatal runtime error, not a panic: the runtime throws, deferred functions do not run, and the process exits. `fmt`'s own recovery — which replaces a panicking `String` result with a `%!v(PANIC=...)` marker — also never triggers, because the method never panics; it simply never returns.
  • How would you recognise this from the crash output alone?
    By the shape of the trace: a short cycle of frames repeating, alternating between the type's `String` method and `fmt`'s internals, with the middle elided. A repeating cycle in a stack trace means unbounded recursion, and the frame in your own package identifies the method to fix.
  • Would the same method recurse if it formatted the receiver with %d?
    No. `fmt` consults `fmt.Stringer` only for `%v`, `%s`, `%q`, `%x` and `%X`, so a numeric verb on an integer-backed type prints the number and returns. Relying on that is fragile though — a later edit to `%v` reintroduces the crash — so convert the receiver anyway.
  • What keeps this out of the codebase without relying on review?
    `go vet`'s printf analyzer reports a `String` method that formats its own receiver as a recursive call, so running `go vet ./...` in CI blocks it. Add a test that asserts `fmt.Sprint(v)` returns the expected text for any type defining `String()`; it exercises exactly the path that crashes.

saying these in an interview costs you the question

  • Says fmt detects the loop and prints a placeholder
  • Claims a deferred recover can catch a stack overflow
  • Calls it a memory leak rather than unbounded recursion
  • Suggests switching the verb as the real fix
  • Thinks the crash needs a profiler to diagnose