Go fmt — Optimize¶
Instructions¶
Each exercise presents inefficient fmt usage. Identify the issue, write an optimized version, and explain. Difficulty: 🟢 Easy, 🟡 Medium, 🔴 Hard.
Exercise 1 🟢 — Sprintf for an Integer Conversion¶
Solution
`Sprintf` goes through the verb parser and the `pp` state. ~2 allocations, ~45 ns/op. `strconv.Itoa` has a fast path for small ints. **Single-value conversions belong in `strconv`.**Exercise 2 🟢 — Sprintf in a Tight Loop for a Key¶
Solution
Each iteration allocates the format buffer and the result string. Buffer reuse halves the cost. `Builder` reuses a `[]byte`; `Sprintf` does not.Exercise 3 🟡 — Println in a Hot Loop¶
Solution
`Println` packs args into `[]any` (1 alloc/call), each arg formats via reflection, output is line-buffered with per-call syscall and the `os.Stdout` mutex. Three options, in order of preference: 1. Move the log out of the loop. 2. `slog` with `JSONHandler` (alloc-free in Go 1.22+): 3. Manual `bufio.Writer`: Hot-loop logs need either zero-allocation structured logging or no logging at all.Exercise 4 🟡 — Sprintf for a Composite Key¶
func tenantKey(tenant, region, kind string) string {
return fmt.Sprintf("%s/%s/%s", tenant, region, kind)
}
Solution
Profile first. If hot: `Grow(n)` upfront avoids buffer-resize re-allocations.Exercise 5 🟡 — Errorf in a Cold Path¶
Solution
**Don't optimize.** Error paths are cold. ~200 ns and 2 allocs is invisible. Optimizing would lose readability and break `errors.Is` for casual readers. **Optimize hot paths only.**Exercise 6 🟡 — Sprintf for a Float in a Hot Path¶
Solution
Direct integer math, ~5x faster: Currency is integer; avoid `float64`. Use only when profile demands.Exercise 7 🔴 — pp Pool Awareness¶
A high-throughput service (50k Sprintf/sec) shows fmt.newPrinter allocations in the heap profile. Why? It should be pooled.
Solution
The `sync.Pool` is per-P; entries can be dropped during GC. After GC, the next call allocates again. Also, buffers > 64 KiB are dropped on purpose: Mitigation: increase GOGC (less frequent GC), or move the hot path off `fmt` entirely (use `Builder`). Pools mitigate but don't eliminate allocation. For true zero-alloc, format directly into a caller-provided buffer.Exercise 8 🔴 — Allocation From Interface Boxing¶
Solution
`Printf` packs `n` into an `any`. Boxing an `int` is normally an alloc, but Go has a fast path: small ints (~`-256` to `255`) are interned. For larger `n`, one alloc per call. Avoid the box entirely: The variadic `...any` is the hidden cost in `Printf`-like benchmarks. Type-specific functions avoid it.Exercise 9 🔴 — Stringer Recursion at Profile Top¶
pprof shows (*pp).doPrintf → handleMethods → String → Sprintf → doPrintf. Stack depth: 200.
Solution
A `String()` method calls `fmt.Sprintf("%v", t)` on its own type. Each call recurses; the goroutine eventually OOMs. Fix: `vet` does NOT catch this. Add a unit test that calls `String()`.Exercise 10 🔴 — Aligning a Million Rows¶
A CLI prints 1M rows with fmt.Printf("%-30s %10d\n", name, count) in 8s.
Solution
Profile: 80% in `doPrintf`, 15% in `os.Stdout.Write`. Step 1 — buffer stdout (drops to ~3s): Step 2 — drop `fmt` (drops to ~1s): Tabular CLIs benefit from `bufio.Writer` first; remove `fmt` second.Exercise 11 🟡 — strings.Builder vs bytes.Buffer¶
func render() string {
var b bytes.Buffer
fmt.Fprintf(&b, "header: %s\n", title)
for _, line := range body {
fmt.Fprintf(&b, " %s\n", line)
}
return b.String()
}
Solution
`bytes.Buffer.String()` allocates a new string. `strings.Builder` avoids that copy: Caveat: `bytes.Buffer` implements `io.Reader`; if you read back what you wrote, keep it.Exercise 12 🔴 — PGO Inlining Through Closures¶
func (l *Logger) Logf(format string, args ...any) {
if !l.enabled { return }
fmt.Fprintf(l.w, format, args...)
}
Profile shows the call site is hot; logger is disabled in production.
Solution
The variadic `args...any` boxes each argument **before** the function returns, even with `enabled = false`. Inlining doesn't help — boxing happens at the call. Early bail at the call site: Or a closure for lazy formatting: Or use leveled APIs that check the level on the caller side, like `slog`: Variadic `...any` boxes args at the call site, not inside.Exercise 13 🔴 — Format Method Allocation¶
Profile shows ~40 B per call.
Solution
`Fprintf` re-enters the formatter and parses verbs. Write directly through `s`: `io.WriteString` uses `WriteString` if `s` implements it (`fmt.pp` does). `Format` runs on the hot path of every `%v` call. Make it allocation-free.Exercise 14 🔴 — Reusable Sprintf via byte slice¶
out := []byte("[")
for i, x := range xs {
if i > 0 { out = append(out, ',') }
out = append(out, fmt.Sprintf("%d", x)...)
}
out = append(out, ']')
Solution
Each `Sprintf` allocates a string only to copy back into `out`. 12x faster, 10000x fewer allocations. Anywhere you `append([]byte, fmt.Sprintf(...)...)` you want `strconv.AppendXxx`.Exercise 15 🟡 — Println vs Printf for a Constant¶
Solution
`Printf` parses the format string for verbs even when there are none. Use `Println("ready")` or, fastest: For a constant message, skip the format parse.Summary: Optimization Hierarchy¶
When fmt shows up in a profile:
- Profile first. Don't optimize on suspicion.
- Single-value conversion?
strconv.Itoa,FormatFloat. - Building a string?
strings.BuilderwithGrow. - Building bytes?
strconv.AppendInt/append. - Writing many lines? Wrap stdout with
bufio.Writer. - Service log? Switch to
slog. - Hot Format method? Write directly through the
State. - Stringer recursion? Fix immediately — it's a bug.
- Variadic boxing? Level-check at the caller; use typed APIs.
- Still hot? Drop down to a hand-rolled
[]bytebuilder.
fmt is correct, readable, and slow. Default to it; replace where benchmarks demand.