Go fmt — Senior Level¶
1. Overview¶
Senior-level mastery of fmt means precise understanding of the three customisation interfaces (Stringer, GoStringer, Formatter), the dispatch order in src/fmt/print.go, the cost of reflection-based fallback, the pp printer-state pool that amortises that cost, and the rules vet's printf analyzer applies.
This level is what distinguishes someone who uses fmt from someone who designs types that fmt formats correctly under every verb, including the edge cases that bite in code review.
2. The Three Customisation Interfaces¶
2.1 Stringer¶
Used by %s and %v. Half the stdlib implements it: time.Time, time.Duration, net.IP, bytes.Buffer, *os.File, time.Weekday.
type Color int
const (Red Color = iota; Green; Blue)
func (c Color) String() string {
return [...]string{"Red", "Green", "Blue"}[c]
}
2.2 GoStringer¶
Called by %#v. Should return Go syntax that recreates the value.
2.3 Formatter¶
Formatter overrides everything: when implemented, neither String() nor GoString() is consulted. State embeds io.Writer plus Width(), Precision(), Flag(c int).
type Q struct{ Op, Key string; Err error }
func (q *Q) Format(f fmt.State, verb rune) {
switch verb {
case 'v':
if f.Flag('+') {
fmt.Fprintf(f, "%s %s\n caused by: %+v", q.Op, q.Key, q.Err)
return
}
fallthrough
case 's':
fmt.Fprintf(f, "%s %s: %v", q.Op, q.Key, q.Err)
case 'q':
fmt.Fprintf(f, "%q", fmt.Sprintf("%s %s: %v", q.Op, q.Key, q.Err))
}
}
fmt.Fprintf(f, ...) writes through the State. Never call fmt.Sprintf("%v", q) inside Format — it re-enters and recurses.
2.4 Dispatch Order¶
Inside printArg/printValue (src/fmt/print.go):
Formatter→ callFormat(f, verb).- Verb
v/sand value implementserror→ useError(). - Verb
v/sand value implementsStringer→ useString(). - Verb
#vand value implementsGoStringer→ useGoString(). - Otherwise reflection-based default formatting.
Two consequences: error is checked before Stringer; Formatter overrides everything, including error.
3. Stringer Recursion Trap¶
type T struct{ X int }
func (t T) String() string {
return fmt.Sprintf("%v", t) // ← infinite recursion
}
Fix with explicit fields, or the alias trick (a local type that does not inherit the String() method):
func (t T) String() string { return fmt.Sprintf("T{X:%d}", t.X) }
// or
func (t T) String() string {
type alias T
return fmt.Sprintf("%+v", alias(t))
}
4. Pointer vs Value Receiver¶
type T struct{ X int }
func (t *T) String() string { return "T" }
var t T
fmt.Println(t) // {0} — value, no String() called
fmt.Println(&t) // T — pointer, String() called
*T's method set includes pointer methods; T's does not.
Rule: define String() on the value receiver unless the type is meant to be used only by pointer (*os.File). Same for Format and GoString.
5. The pp Printer State¶
fmt's formatting goes through a pp (printer) struct holding the output buffer, verb state, and flags. To avoid one allocation per call, the package keeps a sync.Pool:
// src/fmt/print.go
var ppFree = sync.Pool{ New: func() any { return new(pp) } }
func newPrinter() *pp {
p := ppFree.Get().(*pp)
p.panicking, p.erroring, p.wrapErrs = false, false, false
p.fmt.init(&p.buf)
return p
}
func (p *pp) free() {
if cap(p.buf) > 64<<10 { return } // don't pool huge buffers
p.buf = p.buf[:0]
p.arg = nil
p.value = reflect.Value{}
p.wrappedErrs = nil
ppFree.Put(p)
}
Consequences: subsequent calls don't allocate the pp; a 1 MB struct dump drops the buffer to GC instead of pinning it. fmt.Sprintf still allocates the result string and sometimes a []any for variadics.
6. Reflection Cost¶
Without Formatter/Stringer, fmt falls back to reflection: reflect.ValueOf(arg) → switch on Kind() → recurse for struct/slice/map → write leaves with strconv.AppendInt/AppendFloat.
BenchmarkSprintfInt-8 30000000 45 ns/op 16 B/op 2 allocs/op
BenchmarkSprintfStruct-8 5000000 240 ns/op 144 B/op 5 allocs/op
BenchmarkStrconvItoa-8 200000000 7 ns/op 0 B/op 0 allocs/op
A struct of N fields costs roughly O(N) allocations through the recursive walk.
7. The %w Verb in Errorf¶
fmt.Errorf is the only entry point that recognises %w. Inside pp.doPrintf, when verb is w:
- Argument must implement
error; else%!w(<type>=<value>). - Error is appended to
pp.wrappedErrs. - After formatting, the result is
*wrapError(one) or*wrapErrors(two or more). wrapError.Unwrap()returns the single error;wrapErrors.Unwrap()returns[]error.
err := fmt.Errorf("primary: %w; cleanup: %w", e1, e2)
errors.Is(err, e1) // true
errors.Is(err, e2) // true
In any other call, wrappedErrs is ignored and %w becomes a malformed-verb placeholder.
8. The vet printf Analyzer¶
go vet parses literal format strings at compile time and flags:
- Wrong type:
Printf format %d has arg "x" of wrong type string. - Missing/extra arguments.
%woutsideErrorf.- Non-constant format strings (with
--printfuncs).
The analyzer is in golang.org/x/tools/go/analysis/passes/printf. Treat vet warnings as build errors — the cost is one minute per team-month, the savings dozens of %!d(string=...) outages.
9. The stringer Code Generator¶
golang.org/x/tools/cmd/stringer generates String() methods for constant blocks:
//go:generate stringer -type=Status
type Status int
const (StatusPending Status = iota; StatusRunning; StatusDone)
After go generate, status_string.go contains:
// Code generated by "stringer -type=Status"; DO NOT EDIT.
const _Status_name = "StatusPendingStatusRunningStatusDone"
var _Status_index = [...]uint8{0, 13, 26, 36}
func (i Status) String() string {
if i < 0 || i >= Status(len(_Status_index)-1) {
return "Status(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Status_name[_Status_index[i]:_Status_index[i+1]]
}
Real-world consumers: time.Weekday, time.Month, every Kubernetes apimachinery enum. The generator handles non-contiguous values via map[T]string lookup.
10. Custom Formatter for Stack Traces¶
pkg/errors and cockroachdb/errors use Formatter to expose stack traces under %+v:
type withStack struct {
error
stack []uintptr
}
func (w *withStack) Format(s fmt.State, verb rune) {
switch verb {
case 'v':
if s.Flag('+') {
fmt.Fprintf(s, "%+v", w.error)
for _, pc := range w.stack {
fn := runtime.FuncForPC(pc - 1)
file, line := fn.FileLine(pc - 1)
fmt.Fprintf(s, "\n%s\n\t%s:%d", fn.Name(), file, line)
}
return
}
fallthrough
case 's':
fmt.Fprint(s, w.Error())
case 'q':
fmt.Fprintf(s, "%q", w.Error())
}
}
Three observations: s.Flag('+') switches between short and verbose; Fprintf(s, ...) writes through the State; you must handle s, v, and q for error-like values.
11. Reflection vs Fast Paths¶
fmt has fast paths for the common kinds:
// pseudo-code in src/fmt/print.go
switch f := arg.(type) {
case bool: p.fmtBool(f, verb)
case float32: p.fmtFloat(float64(f), 32, verb)
case float64: p.fmtFloat(f, 64, verb)
case int: p.fmtInteger(uint64(f), signed, verb)
case string: p.fmtString(f, verb)
case []byte: p.fmtBytes(f, verb, "[]byte")
default: p.printValue(reflect.ValueOf(arg), verb, 0)
}
The type switch avoids reflection for primitives; formatting int is ~5x cheaper than formatting a struct of one int.
12. State Interface Details¶
type State interface {
Write(b []byte) (n int, err error)
Width() (wid int, ok bool)
Precision() (prec int, ok bool)
Flag(c int) bool
}
A Format that respects width:
func (q Q) Format(f fmt.State, verb rune) {
s := q.string()
if w, ok := f.Width(); ok && len(s) < w {
pad := strings.Repeat(" ", w-len(s))
if f.Flag('-') { s = s + pad } else { s = pad + s }
}
fmt.Fprint(f, s)
}
Most custom Format methods ignore width/precision; document this.
13. Buffer Sharing¶
The pp.buf is reused via the pool. Implications:
- The output of
Sprintfis a freshly allocated string; the buffer is recycled. - Inside
Format, the underlying buffer is shared — do not retain it. If you need formatted bytes:
14. The Errors Chain Today¶
- Go 1.13:
errors.Is,errors.As,errors.Unwrap,%w. - Go 1.20: multiple
%wper call,errors.Join.
Canonical multi-error pattern:
var errs []error
for _, x := range items {
if err := process(x); err != nil {
errs = append(errs, fmt.Errorf("item %s: %w", x.ID, err))
}
}
return errors.Join(errs...)
errors.Join is clearer than Errorf("...; %w; %w", a, b) for collected errors.
15. Custom Verbs¶
fmt doesn't support defining new verbs, but Format lets you respond to any rune:
type Color struct{ R, G, B uint8 }
func (c Color) Format(f fmt.State, verb rune) {
switch verb {
case 'h': fmt.Fprintf(f, "#%02x%02x%02x", c.R, c.G, c.B)
case 'r': fmt.Fprintf(f, "rgb(%d,%d,%d)", c.R, c.G, c.B)
default: fmt.Fprintf(f, "(%d,%d,%d)", c.R, c.G, c.B)
}
}
fmt.Printf("%h\n", Color{255, 128, 0}) // #ff8000
vet will warn about unknown verbs unless you tag the call site.
16. Production Anti-Patterns¶
| Anti-pattern | Why it's bad |
|---|---|
fmt.Errorf("...: %v", err) | Cause unrecoverable via errors.Is |
| Format string built from variables | vet cannot check |
Printf(userInput) | Verb-injection bug |
Sprintf("%v", obj) for hot path | Use Stringer or strconv |
String() calling %v of self | Infinite recursion |
Pointer-receiver String() on value-formatted type | Never fires |
17. Internals at a Glance¶
src/fmt/
doc.go package doc — verb table is here
print.go printer state (pp), Sprintf/Printf/Fprintf entry points
format.go verb-by-verb byte writers (fmtInteger, fmtFloat, ...)
scan.go scanner state (ss), Scan/Sscan/Fscan
errors.go Errorf, wrapError, wrapErrors
The hot loop in doPrintf walks the format string, decodes each directive, calls printArg, advances the argument index, and appends literal text in between.
18. Edge Cases & Pitfalls¶
// 18.1 Format calling fmt on self → recursion
func (q Q) Format(f fmt.State, verb rune) {
fmt.Fprintf(f, "%v", q) // ← bad
}
// 18.2 Stringer returning Sprintf("%v", self) → same trap
// 18.3 Custom verb (e.g. %h) unknown to vet → document supported verbs
// 18.4 Multiple %w with nil — panics in Go 1.20+
fmt.Errorf("a: %w; b: %w", e1, nil) // panics
// 18.5 GoString recursion — same trap
func (id ID) GoString() string { return fmt.Sprintf("%#v", id) } // bad
// 18.6 reflect.Type and %T
t := reflect.TypeOf(42)
fmt.Printf("%v\n", t) // int
fmt.Printf("%T\n", t) // *reflect.rtype — internal type leak
19. Common Mistakes¶
| Mistake | Fix |
|---|---|
String() calling %v of self | Explicit fields or alias |
Format writing to os.Stdout | Use the State |
Pointer-only String() | Promote to value receiver |
Multiple %w with nil | Filter or use errors.Join |
Custom verbs without vet exemption | Document; expect warnings |
Implementing Stringer and error and expecting both | error wins |
20. Common Misconceptions¶
"String() is enough for %v and %+v." Yes for %v/%s/%+v; %#v prefers GoString().
"Format overrides error." Yes — Formatter beats error. If you want both, your Format must call q.Err.Error() itself.
"The pp pool means Sprintf is alloc-free." The pp is pooled, but the result string is freshly allocated.
"Width/precision apply to structs." Default formatting ignores them on composites; they only apply to leaf primitives unless you implement Format.
"%w is just %v plus magic." %w formats like %v AND records the wrapped error. Outside Errorf, the recording is dropped.
21. Tricky Points¶
errorbeatsStringer;Formatterbeats both.%vof a typed nil pointer is<nil>only with a nil-awareString(); otherwise it dereferences and panics.- Width/precision flow into
FormatviaState.Width()/Precision(); you have to implement them. - The
pp.wrappedErrsslice is reused via the pool; do not retain. vetrecognises a fixed list ofprintf-likes; for custom wrappers, add a comment or staticcheck rule.
22. Test¶
type ec struct{ Op string; Err error }
func (e *ec) Error() string { return fmt.Sprintf("%s: %v", e.Op, e.Err) }
func (e *ec) Unwrap() error { return e.Err }
func (e *ec) Format(f fmt.State, verb rune) {
if verb == 'v' && f.Flag('+') {
fmt.Fprintf(f, "%s\n cause: %+v", e.Op, e.Err)
return
}
fmt.Fprint(f, e.Error())
}
func TestFormatterFlag(t *testing.T) {
e := &ec{Op: "load", Err: errors.New("inner")}
if fmt.Sprintf("%v", e) == fmt.Sprintf("%+v", e) {
t.Fatal("expected %v and %+v to differ")
}
}
23. Tricky Questions¶
Q1: A type implements Stringer, error, and Formatter. What does fmt.Println(t) call? A: Format(state, 'v'). Formatter overrides everything.
Q2: Can String() be called on a typed nil pointer? A: Yes, if you write it that way:
Q3: Why does fmt.Errorf("a: %w", nil) panic? A: nil cannot be unwrapped; Go 1.20 added a runtime panic instead of silently producing a non-wrapping error.
Q4: What does %T print for nil? A: <nil> — nil has no type.
24. Cheat Sheet¶
// Stringer / GoStringer / Formatter
func (T) String() string { return "..." }
func (T) GoString() string { return "..." }
func (T) Format(f fmt.State, verb rune) { fmt.Fprintf(f, "...", ...) }
// Stringer recursion fix (alias trick)
func (t T) String() string {
type alias T
return fmt.Sprintf("%+v", alias(t))
}
// Pointer vs value
func (t T) String() string { ... } // works for both T and *T
func (t *T) String() string { ... } // only *T
// stringer codegen
//go:generate stringer -type=MyEnum
// Multiple %w
fmt.Errorf("a: %w; b: %w", e1, e2)
25. Self-Assessment Checklist¶
- I know the dispatch order (Formatter > error > Stringer).
- I avoid the
String()recursion trap. - I prefer value receivers for
String(). - I implement
Formatfor full custom verb support. - I respect
State.Width()/Precision()when relevant. - I run
stringerfor enums. - I read
vet'sprintfanalyzer warnings. - I know the
pppool exists and doesn't help with the result string.
26. Summary¶
At the senior level you treat fmt as a public protocol your types must speak: Stringer for the common case, GoStringer for debugging, Formatter for full control. You know the dispatch order, the recursion trap, the pointer-vs-value rule, and the cost of reflection vs the fast paths. You read the pp source when an allocation profile demands it, and trust stringer and vet for the rest.
27. Further Reading¶
- pkg.go.dev/fmt.
- src/fmt/print.go.
- golang.org/x/tools/cmd/stringer.
- Errors are values — Go blog.
- Working with Errors in Go 1.13.
28. Related Topics¶
- 8.7
slog— what to use instead offmtfor service logs. - 5.4
fmt.Errorf— focused deep dive on%w. - 8.16
sortand friends — companion ergonomic stdlib. - 11 toolchain —
vet,staticcheckintegration.