fmt.Errorf — Find the Bug¶
Each snippet contains a real-world bug related to
fmt.Errorf. Find it, explain it, fix it.
Bug 1 — %v instead of %w¶
var ErrNotFound = errors.New("not found")
func get(id int) error {
return fmt.Errorf("get %d: %v", id, ErrNotFound)
}
if errors.Is(get(7), ErrNotFound) {
// BUG: never fires
}
Bug: %v only inserts the formatted text. It does not wrap; the resulting error has no Unwrap method, so errors.Is cannot find ErrNotFound.
Fix:
The output text is identical; the identity is preserved.
Bug 2 — Wrapping nil¶
Bug: When compute returns nil, fmt.Errorf is still called. The result is a non-nil error whose text is "step: %!w(<nil>)". The caller's if err != nil branch fires and the program treats success as failure.
Fix:
func step(x int) error {
if err := compute(x); err != nil {
return fmt.Errorf("step: %w", err)
}
return nil
}
Always check if err != nil before wrapping.
Bug 3 — %w outside fmt.Errorf¶
Bug: %w is only valid in fmt.Errorf. In Sprintf it produces "got error: %!w(error=...)" — no wrap, ugly text.
Fix: in Sprintf, use %v:
If you actually want to wrap, use fmt.Errorf and chain to whoever logs.
Bug 4 — %w with a non-error argument¶
Bug: reason is a string, not an error. The output contains "%!w(string=...)" and no wrapping happens.
Fix: if there is no underlying error, use %s (or %q):
If you want to wrap, you need an actual error to wrap:
Bug 5 — Two %w before Go 1.20¶
Bug: Pre-Go 1.20, only the first %w wraps. The second renders as %!w(...) and is not found by errors.Is. The bug is silent: no panic, just a partially-functioning chain.
Fix: wrap one and embed the other, or upgrade to Go 1.20+:
// pre-1.20
return fmt.Errorf("a: %w; b: %v", errA, errB)
// 1.20+
return fmt.Errorf("a: %w; b: %w", errA, errB)
Bug 6 — Mixing %w and Error()¶
Bug: The same error is rendered twice — once flat as text, once wrapped. The output reads "step:
Fix: wrap once:
Bug 7 — fmt.Errorf on a static message¶
Bug: Not strictly broken, but wasteful: fmt.Errorf walks the format string, allocates twice, and is not inlined. For a static message, errors.New is faster.
Fix:
Or, even better, define a sentinel at package scope:
Bug 8 — Wrapping in the success path¶
Bug: The wrap happens unconditionally. If compute returns nil, the wrap produces "compute: %!w(<nil>)", the if err != nil then evaluates true, and the function returns a fake error. Same as Bug 2 in a different shape.
Fix:
Or with a deferred wrap:
Bug 9 — Inlining a secret¶
func auth(token string) error {
if !valid(token) {
return fmt.Errorf("auth failed for token %q: %w", token, ErrUnauth)
}
return nil
}
Bug: The token is interpolated into the error message. Once this error reaches a log file, the token is recorded in plaintext.
Fix: never include secrets in error messages. Use a hash, a prefix, or no info at all:
If you must identify the token for debugging, use a hash:
return fmt.Errorf("auth failed for token sha256=%x: %w", sha256.Sum256([]byte(token))[:6], ErrUnauth)
Bug 10 — Capitalized message with trailing period¶
Bug: Go convention is lowercase, no trailing punctuation, because errors compose:
The capital and period look out of place when wrapped.Fix:
Even better, omit "failed to":
Bug 11 — Wrapping at every layer with no new info¶
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 errors.New("the actual error") }
// printout: a: b: c: the actual error
Bug: Each wrap adds only a function name. The chain reads like a stack trace, but with single letters. Hard to operationalize, hard to grep.
Fix: wrap with operation context, not function names:
func loadOrders(userID int) error {
if err := readDB(userID); err != nil {
return fmt.Errorf("load orders for user %d: %w", userID, err)
}
return nil
}
Bug 12 — Type assertion against the unexported wrap struct¶
err := fmt.Errorf("op: %w", base)
if w, ok := err.(*fmt.wrapError); ok { // BUG: cannot import unexported
fmt.Println(w.err)
}
Bug: fmt.wrapError is unexported. The code does not even compile. Even if it did, relying on an internal type is fragile.
Fix: use errors.Unwrap:
Or errors.As for typed extraction.
Bug 13 — Comparing a wrapped error with ==¶
Bug: err is now a *wrapError, not io.EOF itself. == checks reference identity. Wrapping always changes identity.
Fix: use errors.Is:
Bug 14 — Wrapping a typed nil pointer¶
type MyErr struct{ Msg string }
func (e *MyErr) Error() string { return e.Msg }
func validate(x int) error {
var e *MyErr
if x < 0 {
e = &MyErr{"negative"}
}
return fmt.Errorf("validate: %w", e)
}
Bug: When x >= 0, e is a typed nil pointer. fmt.Errorf("validate: %w", e) wraps a non-nil interface (because the type word is non-nil) holding a nil pointer. The resulting error is non-nil, prints "validate:
Fix: check before wrapping; return explicit nil:
func validate(x int) error {
if x < 0 {
return fmt.Errorf("validate: %w", &MyErr{"negative"})
}
return nil
}
Bug 15 — %w in a logging call¶
Bug: log.Printf wraps fmt.Sprintf, which does not understand %w. The output contains %!w(error=...) and is ugly. No wrap happens; logs become useless.
Fix: in logs, use %v:
Or, with a structured logger:
Bug 16 — Re-wrapping the same error twice¶
Bug: Builds a chain of two *wrapError layers in one statement, with the same underlying err. The chain works (two unwraps reach err) but the cost is double and the message reads "inner: outer: ...". Usually unintentional.
Fix: wrap once with both contexts:
Or pick one:
Bug 17 — Operation name as a runtime variable, missing format¶
Bug: Concatenating op into the format string is dangerous. If op contains % characters (e.g., a URL with %20), fmt.Errorf interprets them as format verbs and produces garbage.
Fix:
Always pass dynamic strings as arguments, never concatenate them into the format.
Bug 18 — Ignoring the error from fmt.Errorf chain in tests¶
func TestWrap(t *testing.T) {
err := wrap()
if err.Error() != "expected: text" {
t.Fatal("mismatch")
}
}
Bug: Comparing by .Error() is brittle. If the wrapped error's text changes (different Go version, different OS path format), the test breaks unrelated to the function under test.
Fix: compare identity:
Reserve .Error() comparisons for tests that genuinely depend on the user-facing string.
Bug 19 — Multi-%w with a duplicated argument¶
Bug: Not a crash, but wasteful. Internally wrappedErrs deduplicates by argument index, so Unwrap() returns [a] (one element), but the message text says "a and a." Probably not what you intended.
Fix: if you have one error, wrap it once:
If you genuinely have two distinct errors that happen to be equal, the test should use distinct values.
Bug 20 — Wrapping an error and then logging it; the inner error is logged separately¶
func step() error {
if err := inner(); err != nil {
log.Printf("inner failed: %v", err)
return fmt.Errorf("step: %w", err)
}
return nil
}
// caller
if err := step(); err != nil {
log.Printf("step failed: %v", err)
}
Bug: The inner error is logged twice — once inside step and once outside. Multiplied across a real call stack, you get the same error in 5+ log lines.
Fix: log once at the boundary:
func step() error {
if err := inner(); err != nil {
return fmt.Errorf("step: %w", err)
}
return nil
}
// caller (top-level)
if err := step(); err != nil {
log.Printf("step: %v", err)
}
The inner function wraps; the top-level logs.
Bug 21 — fmt.Errorf inside a tight loop, building the same context¶
for _, item := range items {
if err := process(item); err != nil {
return fmt.Errorf("loop iter %d: %w", time.Now().Unix(), err) // BUG
}
}
Bug: The loop iteration index is not what time.Now().Unix() returns — that is the timestamp. Logically wrong; readers will be confused.
Fix:
for i, item := range items {
if err := process(item); err != nil {
return fmt.Errorf("loop iter %d: %w", i, err)
}
}
Bug 22 — Ignoring the wrap because of a typed-error assertion¶
Bug: The type assertion fails because err is a *wrapError, not a *MyErr. Even though *MyErr is wrapped inside, the outer type does not match.
Fix: use errors.As:
errors.As walks the chain; type assertion does not.