Skip to content

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

type LabelValue string
func (v LabelValue) String() string { return string(v) }

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

  1. Always wrap with %w, never %v, when the caller might inspect.
  2. Don't wrap a wrapped error twice with the same prefix.
  3. Use sentinels for boundaries:
    var ErrNotFound = errors.New("not found")
    return fmt.Errorf("user %d: %w", id, ErrNotFound)
    
  4. Use typed errors when callers branch on errors.As:
    type DBError struct{ Op string; Err error }
    func (e *DBError) Error() string { return fmt.Sprintf("db.%s: %v", e.Op, e.Err) }
    func (e *DBError) Unwrap() error { return e.Err }
    
  5. Multiple %w (Go 1.20+) for joining; or errors.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:

  1. Error() must be safe to call repeatedly — no side effects.
  2. Error() should include enough context to be searchable (load /etc/x: open /etc/x: permission denied, not bad 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:

p := message.NewPrinter(language.German)
p.Printf("There are %d cats\n", 3) // "Es gibt 3 Katzen"

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 Printf first arg.
  • No Sprintf-built SQL.
  • Hot-path conversions use strconv.
  • Service logs go to slog/zap/zerolog.
  • Domain types implement Stringer where logged.
  • Enums use stringer codegen.
  • errorlint is in CI.
  • vet printf 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

  1. errorlint flags non-%w wraps; staticcheck SA1006 flags format-as-variable. Both belong in CI.
  2. vet doesn't see custom Printf-likes; document printfuncs.
  3. slog is Go 1.21+; older codebases may still use third-party loggers — the %w discipline applies regardless.
  4. Format methods that ignore width/precision are common; document the supported flags.
  5. Multiple %w is 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 errorlint in CI.
  • I treat vet printf warnings as build errors.
  • I use stringer for 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


  • 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.