Wrapping & Unwrapping Errors — Optimization¶
Each entry shows wasteful or slow wrap/unwrap code, then improves it. Profile first; only optimize what is measured.
Optimization 1 — Wrapping with no new context¶
Problem: Adds an allocation (~150 ns, one *wrapError) for zero benefit. The chain gains a node with no context.
Better:
Only wrap when you have something to add (operation, input, resource).
Optimization 2 — Wrapping inside a hot loop¶
for _, item := range items {
if err := process(item); err != nil {
return fmt.Errorf("loop iter %d: %w", i, err)
}
}
Problem: Fine if errors are rare. Bad if errors are common — e.g., a parser scanning a million tokens with 10% malformed. Each wrap costs an allocation.
Better: Wrap once at the boundary.
result, err := parseAll(items)
if err != nil {
return fmt.Errorf("parse run: %w", err) // wrap once
}
If you genuinely need per-item context, accumulate into a slice and errors.Join once at the end:
var errs []error
for _, item := range items {
if err := process(item); err != nil {
errs = append(errs, fmt.Errorf("item %v: %w", item, err))
}
}
return errors.Join(errs...)
The errors.Join is one allocation regardless of how many errors.
Optimization 3 — errors.Is against many sentinels¶
Problem: Each call walks the chain independently. For a chain of depth 5 and 3 sentinels, that's 15 method dispatches. For high-rate paths, measurable.
Better: walk once and switch.
for e := err; e != nil; e = errors.Unwrap(e) {
switch e {
case ErrA, ErrB, ErrC:
// matched
return
}
}
This is rarely worth doing — only on profiled hot paths.
Optimization 4 — Sentinel created per call¶
Problem: errors.New("not found") allocates a new *errorString every call. Callers cannot use errors.Is(err, ErrNotFound) because there is no sentinel to compare to.
Better: package-level sentinel.
var ErrNotFound = errors.New("not found")
func find(id int) error {
return fmt.Errorf("find %d: %w", id, ErrNotFound)
}
The sentinel is allocated once at init. Wrapping reuses it. Callers can errors.Is(err, ErrNotFound).
Optimization 5 — Multi-%w for a single cause¶
Problem: Same error wrapped twice. The *wrapErrors allocates a []error slice with two pointers to the same value. Useless.
Better:
Optimization 6 — errors.Join of always-nil errors¶
Problem: If most calls have all-nil arguments, errors.Join still iterates to filter and may allocate a *joinError if at least one is non-nil. For a path where errors are rare, hot-loop callers see needless work.
Better: check first.
(errors.Join does the same internally — this manual check just skips the loop in Join.)
Optimization 7 — Cumulative wrap in a loop¶
var combined error
for _, x := range items {
if err := process(x); err != nil {
combined = fmt.Errorf("item %v: %w", x, errors.Join(combined, err))
}
}
Problem: Each iteration builds a new *wrapError and a new *joinError. Both allocate. Allocations grow linearly with errors.
Better: accumulate once, format once.
var errs []error
for _, x := range items {
if err := process(x); err != nil {
errs = append(errs, fmt.Errorf("item %v: %w", x, err))
}
}
return errors.Join(errs...)
One errors.Join allocation at the end.
Optimization 8 — Long wrap chains in long-lived storage¶
var failureLog []error // package-level
func record(err error) {
failureLog = append(failureLog, fmt.Errorf("at %s: %w", time.Now(), err))
}
Problem: Each error is wrapped (allocates) and stored forever. The wrapped chain stays alive in memory; chains held in failureLog are never collected. Over time the heap grows.
Better: decide whether you need the chain or just the summary.
type FailureRecord struct {
Time time.Time
Summary string
Kind string // a small classification
}
var failureLog []FailureRecord
If you must keep the original error, bound the log size and rotate.
Optimization 9 — errors.As in a loop with reflection¶
Problem: Each errors.As does reflection (reflectlite.TypeOf + AssignableTo). For 10,000 errors that is 10,000 reflection calls.
Better: sometimes a direct type assertion at the top of the chain is enough:
for _, e := range errs {
if pe, ok := e.(*fs.PathError); ok {
process(pe)
continue
}
var pe2 *fs.PathError
if errors.As(e, &pe2) { // only if direct didn't work
process(pe2)
}
}
Only worthwhile when the typical case is "no wrap" and you can shortcut.
Optimization 10 — Chain depth from accidental layering¶
func a() error { return fmt.Errorf("a: %w", b()) }
func b() error { return fmt.Errorf("b: %w", c()) }
func c() error { return fmt.Errorf("c: %w", d()) }
func d() error { return fmt.Errorf("d: %w", e()) }
func e() error { return io.EOF }
Problem: Five layers of wrap with no useful context. Each errors.Is(err, io.EOF) walks five nodes; each error allocates four wrappers.
Better: wrap with useful context, or pass through.
func a() error {
if err := business(); err != nil {
return fmt.Errorf("loading user 42: %w", err)
}
return nil
}
Five "named context" wraps are fine; five "no-op" wraps are pure waste.
Optimization 11 — Custom Is doing string compare¶
Problem: Error() may allocate to format. String comparison is O(len). Reflection-free but still wasteful.
Better: compare a stable identifier (a kind enum, a numeric code).
Optimization 12 — Capturing stack trace on every wrap¶
Problem: pkg/errors.Wrap captures a stack trace. Each capture is ~µs and allocates a []uintptr (variable length, often 8–32 frames). For high-rate paths this is significant.
Better: capture stack only at the original point of failure, not every wrap. cockroachdb/errors separates this. Or use the standard library's fmt.Errorf (no stack capture) and rely on wrap context as your trace.
Optimization 13 — errors.Join building a long error string lazily¶
err := errors.Join(errs...)
log.Print(err) // Error() is called here — builds the joined string
log.Print(err) // Error() called again — string rebuilt
Problem: joinError.Error() builds the joined string each call. If you log the same error multiple times, the string is built multiple times.
Better: materialize once.
Or design so you don't log the same error twice.
Optimization 14 — Wrapping with expensive context¶
Problem: time.Now(), Format, and hostname() are evaluated every call, including success cases if you have eager wrapping. Even on the failure path, the formatting cost is paid per error.
Better: keep the wrap minimal. Time and host belong to the logger, not the error itself.
Optimization 15 — errors.Unwrap in a manual walk when errors.Is would do¶
Problem: This is exactly errors.Is minus the Is method support. It misses custom Is overrides and panics on non-comparable layers.
Better: just use errors.Is.
Benchmarking¶
Always measure before optimizing:
func BenchmarkWrap(b *testing.B) {
leaf := errors.New("leaf")
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = fmt.Errorf("op: %w", leaf)
}
}
func BenchmarkIs(b *testing.B) {
leaf := errors.New("leaf")
chain := fmt.Errorf("a: %w", fmt.Errorf("b: %w", fmt.Errorf("c: %w", leaf)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = errors.Is(chain, leaf)
}
}
Look for allocs/op, B/op, and ns/op. Compare wrap-heavy vs sentinel-only versions.
For allocation profiling:
Search for *wrapError, *wrapErrors, *joinError. If they are in the top 10 by count, they may be worth attention.
When NOT to Optimize¶
- Cold paths — handlers fire 1/s, wraps cost nothing in aggregate.
- Top-level wrapping at API boundaries — readability beats nanoseconds.
- Error paths that are genuinely rare — your service does not hit them at scale, so optimization is invisible.
- Tests — clarity wins; tests do not run in production.
When in doubt: measure. Premature optimization of wrap chains produces unreadable code with no measurable benefit.
Summary¶
The fast path of wrap/unwrap is already cheap in Go: if err == nil short-circuits, sentinel comparisons via errors.Is are sub-microsecond, walks are linear in chain length. The slow parts — wrapping with no context, cumulative joins in loops, stack trace capture on every wrap, custom Is doing string compares — only matter on high-rate failure paths. Profile first. Optimize only what shows up. Keep the chain useful and short, and the wrap will pay for itself many times over in debug time saved.