Go fmt — Professional / Production Level¶
1. Overview¶
In production, fmt is the package you reach for less and less. Most of its appearances are in tests, errors, and CLI tools; service logs go to slog/zap/zerolog, hot-path conversions go to strconv, and structured output goes to encoding/json. What remains is exactly where fmt shines: error wrapping, Stringer methods on domain types, and one-shot formatting.
This leaf documents the rules production teams settle on after a few on-call shifts: where fmt is allowed, what vet/staticcheck catch, the Stringer and Formatter patterns from real OSS, and the trade-offs between Printf-line and structured logging.
2. The "Where is fmt Allowed" Convention¶
| Place | fmt allowed? | Reason |
|---|---|---|
Domain String() methods | Yes | What Stringer is for |
Error wrapping (Errorf with %w) | Yes | Canonical idiom |
tests/ and examples/ | Yes | Output is for humans |
| CLI tools | Yes | Output is for humans |
| HTTP handler bodies | Sometimes | Prefer json.Encoder for APIs |
| Service logs (long-running) | No | Use slog/zap/zerolog |
| Hot-path string building | No | Use Builder + strconv |
| User-facing translations | No | Use golang.org/x/text/message |
golangci-lint ships rules to enforce this; see Section 5.
3. Stringer in the Standard Library¶
| Type | File | Notes |
|---|---|---|
time.Time | src/time/format.go | RFC 3339 default |
time.Duration | src/time/time.go | 1h2m3s form |
time.Weekday / Month | generated by stringer | Sunday, January |
net.IP / net.IPNet | src/net/ip.go | dotted/colon, CIDR |
*os.File | src/os/file.go | filename |
bytes.Buffer | src/bytes/buffer.go | string view |
regexp.Regexp | src/regexp/regexp.go | source pattern |
reflect.Type | src/reflect/type.go | type name |
Every one is what makes fmt.Println(time.Now()) work without ceremony.
4. Real OSS Examples¶
4.1 cockroachdb/cockroach — tree.Expr Formatter¶
// pkg/sql/sem/tree/format.go (simplified)
type Expr interface { Format(ctx *FmtCtx) }
func (e *BinaryExpr) Format(ctx *FmtCtx) {
ctx.Format(e.Left)
ctx.WriteByte(' ')
ctx.WriteString(e.Operator.String())
ctx.WriteByte(' ')
ctx.Format(e.Right)
}
CockroachDB does not use fmt.Formatter directly; it wraps a bytes.Buffer in FmtCtx so formatting carries context (redacted values, alias maps, locality). Lesson: fmt.Formatter is convenient but inflexible — for a SQL engine you write your own Format protocol.
4.2 prometheus/common — model.LabelValue¶
Direct Stringer on a string newtype: LabelValue is unicode-safe but not always UTF-8 valid; String() is the canonical conversion, used everywhere in alertmanager output.
4.3 grpc-go — status.Status¶
func (s *Status) String() string {
return fmt.Sprintf("rpc error: code = %s desc = %s",
codes.Code(s.s.GetCode()), s.s.GetMessage())
}
codes.Code itself implements Stringer (generated by stringer). This is the format you see in every gRPC panic and log line.
4.4 etcd-io/raft — raftpb.MessageType¶
// raft/raftpb/raft.pb.go (proto-generated)
func (x MessageType) String() string {
return proto.EnumName(MessageType_name, int32(x))
}
Proto enums get a free Stringer from the proto runtime; etcd's log lines (received MsgVote from 5...) feed through it.
4.5 kubernetes — apimachinery enums¶
//go:generate stringer -type=PatchType
type PatchType string
const (
JSONPatchType PatchType = "application/json-patch+json"
MergePatchType PatchType = "application/merge-patch+json"
)
stringer is the convention for enums across Kubernetes.
4.6 hashicorp/go-multierror — Format¶
func (e *Error) Error() string {
fn := ListFormatFunc
if e.ErrorFormat != nil { fn = e.ErrorFormat }
return fn(e.Errors)
}
A configurable formatter; predates errors.Join. Modern code uses errors.Join from the stdlib.
4.7 pkg/errors — Stack Trace via Format¶
func (w *withStack) Format(s fmt.State, verb rune) {
switch verb {
case 'v':
if s.Flag('+') {
fmt.Fprintf(s, "%+v", w.Cause())
w.stack.Format(s, verb)
return
}
fallthrough
case 's':
io.WriteString(s, w.Error())
case 'q':
fmt.Fprintf(s, "%q", w.Error())
}
}
%+v exposes the stack; plain %s/%v show the message. Pattern reproduced in cockroachdb/errors, pingcap/errors, and most ORMs.
5. Lint Rules¶
5.1 vet's printf analyzer¶
Built into go vet; flags wrong-type args, missing/extra args, non-constant format strings, %w outside Errorf. Treat its warnings as errors in CI.
5.2 staticcheck¶
| Check | Description |
|---|---|
SA1006 | Printf with dynamic format string — verb-injection bug |
SA9006 | Printf-style format-arg type mismatch |
S1025 | Unnecessary Sprintf (e.g. Sprintf("%s", s) → s) |
SA1010 | regexp.MustCompile of Sprintf result |
5.3 revive¶
| Rule | Description |
|---|---|
error-strings | Errors not capitalized or punctuated (compose under %w) |
unnecessary-format | Errorf with no verbs → errors.New |
5.4 golangci-lint preset¶
linters:
enable: [govet, staticcheck, revive, errorlint]
linters-settings:
govet:
enable: [printf]
errorlint:
errorf: true
errorf-multi: true
asserts: true
comparison: true
errorlint deserves special mention: it catches Errorf("...: %v", err) and recommends %w, plus err == sentinel and recommends errors.Is.
6. fmt vs slog/zap/zerolog¶
| Aspect | fmt.Println/Printf | slog/zap/zerolog |
|---|---|---|
| Output | Free-form text | Structured (JSON/logfmt) |
| Allocations | ~2/call | 0 with zerolog or slog.JSONHandler (Go 1.22+) |
| Filtering | Grep | Severity + key matching |
| Aggregation | Manual | Native key indexing |
| Latency | ~100ns/call | ~50ns (zerolog), ~80ns (slog) |
| Schema | Implicit | Explicit |
For a 10k-lines/sec service the per-call cost matters and the structure matters more — log aggregators charge by ingested bytes, and structured logs compress and index better.
Keep fmt for: CLI tools, test failure messages, error wrapping. Switch when: a log line is consumed by a machine.
// Before
fmt.Printf("user=%d action=%s latency=%v\n", uid, action, dur)
// After
slog.Info("action complete", "user", uid, "action", action, "latency", dur)
7. The %w Discipline¶
- Always wrap with
%w, never%v, when the caller might inspect. - Don't wrap a wrapped error twice with the same prefix.
- Use sentinels for boundaries:
- Use typed errors when callers branch on
errors.As: - Multiple
%w(Go 1.20+) for joining; orerrors.Join.
8. Stringer for Logs and Dashboards¶
type Severity int
const (SevDebug Severity = iota; SevInfo; SevWarn; SevError)
//go:generate stringer -type=Severity -trimprefix=Sev
Severity.String() produces Debug, Info, Warn, Error. -trimprefix drops the Sev. Logs are searchable; dashboards readable.
9. Observability and Errors¶
Honeycomb, OpenTelemetry, and Datadog typically capture err.Error() as the span message. Implications:
Error()must be safe to call repeatedly — no side effects.Error()should include enough context to be searchable (load /etc/x: open /etc/x: permission denied, notbad config).
fmt.Errorf("op %s: %w", op, err) produces searchable messages naturally because op shows up in the prefix.
10. Format Strings in Internationalisation¶
fmt is not an i18n package. Use golang.org/x/text/message instead:
message.Printer understands plurals, genders, and reorderable arguments. fmt does not.
11. Real-World Anti-Patterns¶
// 11.1 fmt.Sprintf in HTTP handlers — JSON-escaping bug
fmt.Fprintf(w, `{"user":"%s","age":%d}`, u.Name, u.Age) // BAD
json.NewEncoder(w).Encode(u) // good
// 11.2 fmt.Errorf with %v for wrapping — chain broken
return fmt.Errorf("load: %v", err) // BAD; errorlint flags
return fmt.Errorf("load: %w", err) // good
// 11.3 fmt.Println in hot loops — 1M allocations/sec
for _, x := range items { fmt.Println("processing", x.ID) } // BAD
slog.Info("processing", "id", x.ID) // good
// 11.4 fmt.Sprintf for SQL — injection
q := fmt.Sprintf("SELECT * FROM users WHERE id=%s", req.ID) // CRITICAL
db.Query("SELECT * FROM users WHERE id=$1", req.ID) // good
// 11.5 Custom Format dropping info — must handle %v and %+v
func (e *DBError) Format(s fmt.State, verb rune) {
fmt.Fprintf(s, "%s", e.Op) // ← drops e.Err
}
12. Production Checklist¶
- Format strings are constants.
- All wrap-style errors use
%w. - No user input in
Printffirst arg. - No
Sprintf-built SQL. - Hot-path conversions use
strconv. - Service logs go to
slog/zap/zerolog. - Domain types implement
Stringerwhere logged. - Enums use
stringercodegen. -
errorlintis in CI. -
vetprintf warnings break the build.
13. Common Mistakes¶
| Mistake | Fix |
|---|---|
fmt.Println in service code | slog.Info |
Errorf with %v | %w |
| Sprintf-built SQL | parameterised queries |
| Stringer recurses on self | alias type or explicit fields |
| Pointer-only Stringer | value receiver |
| Custom Format dropping info | handle all common verbs |
14. Common Misconceptions¶
"fmt is fine for service logs." Free-form text is hard to index. Structured logging wins at scale.
"Sprintf is faster than json.Marshal." For one field, yes; for a struct of N fields, json is roughly the same and produces parsable output.
"%+v is enough for debugging." For primitives. For nested structs, indented json is more readable.
"vet catches all printf bugs." Only on literal format strings. Runtime-built formats are silent.
"errorlint is overkill." The %w-discipline rule alone has caught dozens of bugs in incident postmortems.
15. Tricky Points¶
errorlintflags non-%wwraps;staticcheckSA1006 flags format-as-variable. Both belong in CI.vetdoesn't see customPrintf-likes; documentprintfuncs.slogis Go 1.21+; older codebases may still use third-party loggers — the%wdiscipline applies regardless.Formatmethods that ignore width/precision are common; document the supported flags.- Multiple
%wis Go 1.20+; older codebases must wrap manually.
16. Test (Production-Style)¶
func TestNotFoundUnwraps(t *testing.T) {
_, err := userdb.Get(ctx, "missing")
if !errors.Is(err, userdb.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
Assumes the implementation wraps with %w and exposes sentinels.
17. Tricky Questions¶
Q1: Why does errorlint flag fmt.Errorf("...: %v", err)? A: The wrapped error is no longer recoverable via errors.Is — the chain is broken.
Q2: How do you write a Stringer for a redacted field? A:
func (c Credentials) String() string {
return fmt.Sprintf("Credentials{User:%q, Pass:[redacted]}", c.User)
}
Q3: When is fmt.Sprintf justified in a hot path? A: When the call rate is bounded and clarity beats nanoseconds. A retry-once error message: yes. A per-request log line: no.
18. Cheat Sheet¶
// Wrap (production discipline)
return fmt.Errorf("op %s: %w", op, err)
// Stringer for enum
//go:generate stringer -type=Status -trimprefix=Status
// Format method respecting +v
func (e *T) Format(s fmt.State, verb rune) {
switch verb {
case 'v':
if s.Flag('+') {
fmt.Fprintf(s, "%s\n cause: %+v", e.Op, e.Err)
return
}
fallthrough
case 's':
fmt.Fprint(s, e.Error())
}
}
// errorlint config
linters: { enable: [errorlint] }
linters-settings: { errorlint: { errorf: true } }
19. Self-Assessment Checklist¶
- I run
errorlintin CI. - I treat vet
printfwarnings as build errors. - I use
stringerfor enums. - I switch hot logs to
slog. - I never build SQL with
Sprintf. - I keep format strings constant.
- I wrap with
%w.
20. Summary¶
fmt survives in production for three reasons: domain Stringer methods, error wrapping with %w, and CLI/test output. Everywhere else — service logs, JSON responses, hot-path conversions — better packages exist. Lock down the discipline with vet, staticcheck, and errorlint, and use stringer for enums.
21. Further Reading¶
- Go blog — Working with Errors in Go 1.13
- Go blog — log/slog
- errorlint
- staticcheck SA1006/SA9006
- stringer
- pkg/errors Format pattern
- cockroachdb/errors
22. Related Topics¶
- 8.7
slog— what to switch to. - 5.4
fmt.Errorf— focused deep dive on%w. - 8.4
encoding/json— for HTTP handler bodies. - 11 toolchain —
vet,staticcheck,golangci-lint.