Error Design — Best Practices — Find the Bug¶
Each snippet contains a real-world bug related to error design. Find it, explain it, fix it.
Bug 1 — Capitalized message that breaks composition¶
Bug: Capitalized first letter and trailing period. When wrapped, the result is load /etc/x.conf: Failed to open config file.: open /etc/x.conf: no such file or directory — three sentences pretending to be one.
Fix:
Or, if you have the path and the cause:
Bug 2 — %v instead of %w¶
if err := db.Query(...); err != nil {
return fmt.Errorf("query users: %v", err)
}
// elsewhere:
if errors.Is(err, sql.ErrNoRows) { ... } // never matches
Bug: %v interpolates the message but does not wrap. The chain is broken; errors.Is cannot see through the layer.
Fix:
Bug 3 — Stringly-typed error matching¶
Bug: Brittle. The next message rewording ("missing", "absent", "could not locate") silently breaks this branch.
Fix:
Bug 4 — Logging and returning¶
func step() error {
if err := work(); err != nil {
log.Printf("step failed: %v", err)
return err
}
return nil
}
Bug: Lower layers should not log. The caller almost certainly logs as well, producing duplicate lines for the same failure.
Fix: wrap and return; let the boundary log.
Bug 5 — Typed nil returned as error¶
type MyErr struct{ Field string }
func (e *MyErr) Error() string { return e.Field + ": bad" }
func validate(s string) error {
var e *MyErr
if s == "" {
e = &MyErr{Field: "name"}
}
return e // returns non-nil interface even when e is nil pointer
}
err := validate("ok")
if err != nil {
fmt.Println("bug:", err) // prints "bug: <nil>: bad" or panics
}
Bug: An interface holding a nil concrete pointer is not nil. The error check accidentally fires on success.
Fix: return literal nil:
Bug 6 — Sentinel changes per init¶
Bug: A sentinel must be a constant of identity. Recomputing it produces a different value on each binary build (and breaks across processes that compare by identity, though Go does not do that for values).
Fix: the sentinel carries kind, the wrap carries data:
var ErrCannotConnect = errors.New("cannot connect")
return fmt.Errorf("connect %s: %w", host, ErrCannotConnect)
Bug 7 — Panic for control flow¶
func parse(b []byte) (Token, error) {
defer func() {
if r := recover(); r != nil {
// ... convert to error
}
}()
if len(b) == 0 {
panic("empty input")
}
// ...
}
Bug: Panic-and-recover used as a control flow shortcut. This is what return err is for. Worse: hidden panic paths are extremely hard to debug.
Fix:
func parse(b []byte) (Token, error) {
if len(b) == 0 {
return Token{}, errors.New("empty input")
}
// ...
}
Reserve panic for programmer errors (broken invariants), not operational errors (bad input).
Bug 8 — Public sentinel renamed without deprecation¶
// v1.0
var ErrNotFound = errors.New("not found")
// v1.1
var ErrMissing = errors.New("not found") // renamed without alias
Bug: Every caller of errors.Is(err, ErrNotFound) now fails to compile. This is a major-version change being shipped as a minor.
Fix: keep both, mark old as deprecated:
var ErrMissing = errors.New("not found")
// Deprecated: use ErrMissing instead.
var ErrNotFound = ErrMissing
Remove ErrNotFound only in the next major version.
Bug 9 — Goroutine that swallows its error¶
func startWorker() {
go func() {
if err := workForever(); err != nil {
// no return path; nobody sees this
}
}()
}
Bug: The goroutine ignores its error. The launching function gets no signal of the failure.
Fix: route the error somewhere:
func startWorker() <-chan error {
out := make(chan error, 1)
go func() {
out <- workForever()
}()
return out
}
Or use errgroup. Or panic-and-recover-and-log if you cannot route. Anything but silent loss.
Bug 10 — Embedded sentinel loses identity through %v¶
Bug: %v interpolates the message; the chain does not exist.
Fix: use %w.
Bug 11 — errors.As on a non-pointer target¶
Bug: errors.As panics at runtime with "errors: target must be a non-nil pointer".
Fix:
The target must be *T where T is your error type (or **T for pointer types).
Bug 12 — Sentinel without Unwrap in custom type¶
type MyErr struct {
inner error
}
func (e *MyErr) Error() string { return "my: " + e.inner.Error() }
// no Unwrap!
Bug: errors.Is(err, e.inner) returns false because the chain is broken: MyErr is the outer error, but it does not expose the inner.
Fix: implement Unwrap():
Now the chain walk reaches the inner error.
Bug 13 — Doubly wrapped boilerplate¶
Bug: Two layers of error: boilerplate that add no information. The final message is error: error: <inner>.
Fix: keep one wrap with information, drop the noise:
Bug 14 — Sentinel for every internal kind¶
package userstore
var (
ErrConnectionLost = errors.New("connection lost")
ErrConnectionTimeout = errors.New("connection timeout")
ErrConnectionRefused = errors.New("connection refused")
ErrConnectionReset = errors.New("connection reset")
ErrConnectionAborted = errors.New("connection aborted")
ErrAuthFailed = errors.New("auth failed")
ErrAuthExpired = errors.New("auth expired")
ErrAuthRevoked = errors.New("auth revoked")
// 30 more...
)
Bug: Every export is a public API commitment. Most of these are internal kinds the caller will never branch on individually.
Fix: collapse into kinds, keep the structured field for detail:
var (
ErrTransient = errors.New("transient")
ErrAuth = errors.New("auth")
ErrInternal = errors.New("internal")
)
Or use a single Error type with a Kind enum.
Bug 15 — panic(err) to skip handling¶
func mustOpen(path string) *os.File {
f, err := os.Open(path)
if err != nil {
panic(err) // I don't want to write `if err != nil`
}
return f
}
// caller
defer func() { recover() }() // catches everywhere
mustOpen("/etc/x")
Bug: Panic used to dodge error handling. Recovery is global, masking many other bugs.
Fix: return the error and check it. Reserve Must* helpers for startup-only code where a failure means the program cannot run:
func mustOpen(path string) *os.File {
f, err := os.Open(path)
if err != nil {
log.Fatal(err) // startup; cannot continue
}
return f
}
Bug 16 — Logging the secret in error wrap¶
Bug: The wrap embeds the password. Now every log line containing this error contains a plaintext password.
Fix: never include secrets in error messages:
Bug 17 — recover and continue in business logic¶
func process(items []Item) error {
for _, it := range items {
defer func() {
if r := recover(); r != nil {
log.Println("recovered:", r)
// and silently continue
}
}()
handle(it)
}
return nil
}
Bug: Two issues: 1. defer inside a for-loop accumulates one defer per iteration — they all run at function end, not per iteration. 2. Recovery without escalation hides real bugs.
Fix:
func process(items []Item) error {
for _, it := range items {
if err := safeHandle(it); err != nil {
return fmt.Errorf("item %v: %w", it.ID, err)
}
}
return nil
}
func safeHandle(it Item) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
handle(it)
return nil
}
defer is now scoped to one item; panic is converted to an error the caller sees.
Bug 18 — errors.Join of one error¶
Bug: Wraps a single error in a multi-error envelope for no benefit. errors.Is/As still works but the type is needlessly complex.
Fix:
errors.Join is for multiple errors; for one, return it directly.
Bug 19 — Comparing wrapped errors with ==¶
Bug: == compares the wrapping error's pointer, not the chain. Wrapping always changes identity.
Fix:
errors.Is walks the chain. Always use it for sentinel matching.
Bug 20 — Returning user-facing message instead of an error¶
func parseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, errors.New("Please enter a valid age between 0 and 150!")
}
// ...
}
Bug: The error message is a user-facing string with capitalization, punctuation, and exclamation. It will be embedded in logs, then probably translated by a translation system — but it cannot be, because there is no stable code.
Fix: internal error in idiomatic style; user-facing message generated at the boundary:
var ErrInvalidAge = errors.New("invalid age")
func parseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse age %q: %w", s, ErrInvalidAge)
}
// ...
}
// at the boundary
case errors.Is(err, ErrInvalidAge):
writeJSON(w, 400, APIError{Code: "user.invalid_age", Message: t.Translate(ctx, "user.invalid_age")})
Bug 21 — Mutable error type¶
type MyErr struct {
Field string
Count int
}
func (e *MyErr) Error() string {
e.Count++
return fmt.Sprintf("%s (called %d times)", e.Field, e.Count)
}
Bug: Error() has side effects. Two consumers (a log line and an assertion) get different strings; structured loggers may call Error() multiple times.
Fix: Error() must be pure.
If you need to count Error() calls, do it externally — but you almost certainly do not.
Bug 22 — Using errors.Is to test identity-based equality of values¶
type ValErr struct{ Code int }
func (e *ValErr) Error() string { return "val" }
a := &ValErr{Code: 1}
b := &ValErr{Code: 1}
errors.Is(a, b) // false
Bug: errors.Is falls back to ==, which compares pointers. Two *ValErr with the same fields are not pointer-equal.
Fix: implement Is:
Or use a value-receiver type, where Go's == does field comparison.
Bug 23 — _ = f() swallowing¶
Bug: A failed unmarshal leaves v in a partial state; the program proceeds as if everything is fine. The _ = is a deliberate "I do not care" — but you should care.
Fix:
A _ = on an error return should be commented if it is intentional, otherwise replaced with proper handling.
Bug 24 — Stack trace embedded in error string¶
Bug: Error messages should not contain stack traces — they bloat logs, leak internals if the message is sent to a client, and are hard to parse out programmatically.
Fix: capture the stack as a separate field of the error type, or log it separately at the boundary:
type withStack struct {
err error
pcs []uintptr
}
func (e *withStack) Error() string { return e.err.Error() }
func (e *withStack) Unwrap() error { return e.err }
The boundary logger then prints both: slog.Error(..., "err", err.Error(), "stack", formatStack(e.pcs)). Stack stays out of the error message.
Bug 25 — Public typed error with mutable field¶
Bug: A public mutable field on an error type invites callers to mutate. The error becomes shared mutable state.
Fix: keep visible state immutable. If you need a counter, keep it private and provide methods:
type ValidationError struct {
Field string
Reason string
visited atomic.Int32 // private
}
func (e *ValidationError) Visit() { e.visited.Add(1) }
func (e *ValidationError) Visits() int32 { return e.visited.Load() }
But really: do not put a counter on an error. Keep errors as inert values.