Sentinel Errors — Find the Bug¶
Each snippet contains a real-world bug related to sentinel errors. Find it, explain it, fix it.
Bug 1 — == against a wrapped sentinel¶
var ErrNotFound = errors.New("not found")
func find(id int) error {
return fmt.Errorf("user %d: %w", id, ErrNotFound)
}
func main() {
err := find(7)
if err == ErrNotFound {
fmt.Println("not found")
}
}
Bug: find returns a wrapped error whose dynamic type is *fmt.wrapError, not *errorString. err == ErrNotFound compares the outer interface header against the sentinel — they differ, so the branch never fires.
Fix: use errors.Is:
Bug 2 — %v instead of %w¶
var ErrPermission = errors.New("permission denied")
func auth(user string) error {
return fmt.Errorf("auth %q: %v", user, ErrPermission)
}
func main() {
err := auth("guest")
fmt.Println(errors.Is(err, ErrPermission)) // false
}
Bug: %v formats the sentinel as a string and embeds it. The result has no Unwrap method; errors.Is cannot find the sentinel.
Fix: use %w:
Bug 3 — Sentinel created inside a function¶
func find(id int) error {
return errors.New("not found")
}
var ErrNotFound = errors.New("not found")
func main() {
err := find(0)
fmt.Println(errors.Is(err, ErrNotFound)) // false
}
Bug: Each call to find creates a new *errorString with the message "not found". The package-level ErrNotFound is a different *errorString pointer with the same message. errors.Is compares pointers, not strings — they never match.
Fix: return the sentinel:
Bug 4 — Treating io.EOF as a real error¶
func count(r io.Reader) (int, error) {
n := 0
buf := make([]byte, 1024)
for {
m, err := r.Read(buf)
n += m
if err != nil {
return 0, fmt.Errorf("read: %w", err)
}
}
}
Bug: When the reader hits the end of the stream, it returns io.EOF. The code treats any non-nil error as a failure, so it returns 0, "read: EOF" even though everything read fine. Two issues: the count is reset to 0, and EOF is wrongly bubbled up.
Fix:
func count(r io.Reader) (int, error) {
n := 0
buf := make([]byte, 1024)
for {
m, err := r.Read(buf)
n += m
if errors.Is(err, io.EOF) {
return n, nil
}
if err != nil {
return n, fmt.Errorf("read: %w", err)
}
}
}
Bug 5 — Sentinel collision between packages¶
package a
var ErrNotFound = errors.New("not found")
package b
var ErrNotFound = errors.New("not found")
// elsewhere
if errors.Is(err, a.ErrNotFound) { /* ... */ }
if errors.Is(err, b.ErrNotFound) { /* ... */ }
Bug: Even though both messages are identical, a.ErrNotFound and b.ErrNotFound are two distinct values. An error from a does not match b.ErrNotFound. Callers who don't know which package produced the error end up checking both — fragile and noisy.
Fix (option A): alias one to the other:
Fix (option B): translate at the boundary — package b returns its own ErrNotFound after detecting a.ErrNotFound.
Bug 6 — Sentinel-shaped, but actually a generator¶
Bug: Sentinels must be stable. This one bakes the init time into the message. The value is stable (one *errorString for the program), so errors.Is still works, but the message is per-process and confusing in logs.
Fix: static string:
If you need to log time, do it in the logger, not in the sentinel.
Bug 7 — errors.Is(nil, sentinel)¶
var ErrNotFound = errors.New("not found")
func main() {
var err error
if errors.Is(err, ErrNotFound) {
fmt.Println("matched") // never prints
} else {
fmt.Println("not matched") // BUG-shaped: programmer expected matched
}
}
Bug: errors.Is(nil, target) is false whenever target is non-nil. The programmer who wrote this likely meant to check err != nil first or to handle the success case with a separate err == nil branch.
Fix: treat nil as success:
if err == nil {
fmt.Println("ok")
} else if errors.Is(err, ErrNotFound) {
fmt.Println("not found")
} else {
fmt.Println("other:", err)
}
Bug 8 — Comparing sentinel by message¶
Bug: Brittle. Breaks if the sentinel is wrapped ("user 7: not found" does not match), if the message changes in a future version, or if a different package returns a similar string.
Fix: sentinel comparison with errors.Is:
Bug 9 — Re-wrapping a wrapped sentinel inside a hot loop¶
var ErrSkip = errors.New("skip")
func process(items []item) error {
for _, it := range items {
if err := handle(it); err != nil {
err = fmt.Errorf("item %v: %w", it, err) // BUG-shaped
if errors.Is(err, ErrSkip) {
continue
}
return err
}
}
return nil
}
Bug: Subtle. The wrap is done on the failure path only, which is fine for correctness. But on a hot path with many ErrSkip results, every iteration allocates a fresh *fmt.wrapError. A million skips per second produce a million garbage objects.
Fix: detect before wrapping, and only wrap when actually returning:
for _, it := range items {
err := handle(it)
if errors.Is(err, ErrSkip) {
continue
}
if err != nil {
return fmt.Errorf("item %v: %w", it, err)
}
}
Bug 10 — Sentinel comparison crossing wrapped levels via ==¶
var ErrFoo = errors.New("foo")
func a() error { return fmt.Errorf("a: %w", ErrFoo) }
func b() error { return fmt.Errorf("b: %w", a()) }
func main() {
err := b()
if errors.Unwrap(err) == ErrFoo {
fmt.Println("matched")
}
}
Bug: errors.Unwrap(err) only undoes one layer. b() wrapped a()'s output, so the unwrapped value is the *fmt.wrapError from a(), not the sentinel. == ErrFoo is false even though errors.Is(err, ErrFoo) would be true.
Fix:
errors.Is walks until it finds a match.
Bug 11 — Two sentinels, one switch¶
var (
ErrA = errors.New("a")
ErrB = errors.New("b")
)
func handle(err error) {
switch err {
case ErrA:
fmt.Println("got A")
case ErrB:
fmt.Println("got B")
default:
fmt.Println("other")
}
}
func main() {
handle(fmt.Errorf("ctx: %w", ErrA))
}
Bug: The switch err form is value-equality based — same trap as ==. The wrapped error never matches ErrA or ErrB.
Fix: type-switch on the unwrapped chain or use explicit errors.Is:
switch {
case errors.Is(err, ErrA):
fmt.Println("got A")
case errors.Is(err, ErrB):
fmt.Println("got B")
default:
fmt.Println("other")
}
Bug 12 — context.Canceled counted as a 5xx error¶
func handle(w http.ResponseWriter, r *http.Request) {
if err := s.do(r.Context()); err != nil {
metrics.IncrCounter("http.5xx")
log.Printf("error: %v", err)
http.Error(w, "internal", 500)
return
}
}
Bug: When the user closes their browser, the context is cancelled and s.do returns context.Canceled. The handler counts that as a 5xx, logs it, and returns 500. On-call gets paged because users are clicking away.
Fix: treat cancellation as success-equivalent:
if err := s.do(r.Context()); err != nil {
if errors.Is(err, context.Canceled) {
return // user gone; do not count
}
metrics.IncrCounter("http.5xx")
log.Printf("error: %v", err)
http.Error(w, "internal", 500)
return
}
Bug 13 — Re-exporting a sentinel as a different value¶
package outer
import "myorg/inner"
// BUG: introduces a new sentinel that LOOKS the same
var ErrNotFound = errors.New("not found")
func Find(id int) error {
err := inner.Lookup(id)
if errors.Is(err, inner.ErrNotFound) {
return ErrNotFound
}
return err
}
Bug: Callers who do errors.Is(err, outer.ErrNotFound) will match. Callers who do errors.Is(err, inner.ErrNotFound) will not match — the translation lost the inner sentinel. If both were intended to match, this breaks.
Fix (option A): alias instead of re-creating:
Fix (option B): wrap to preserve the inner identity if you want both to work:
Bug 14 — Sentinel inside a function literal¶
func handler() error {
notFound := errors.New("not found") // BUG
if missing() {
return notFound
}
return nil
}
// elsewhere
if errors.Is(err, /* what? */) { ... }
Bug: The sentinel is local to the function. Each call creates a new value. No caller has a reference to compare against. The errors.Is check at the call site has nothing to pass as target.
Fix: promote to package scope:
var ErrNotFound = errors.New("not found")
func handler() error {
if missing() {
return ErrNotFound
}
return nil
}
Bug 15 — Forgotten errors.As for typed errors¶
var ErrParse = errors.New("parse error")
func parse(s string) error {
return &json.SyntaxError{Offset: 5, /* ... */}
}
func main() {
err := parse("{")
if errors.Is(err, ErrParse) {
fmt.Println("parse error") // BUG: never fires
}
}
Bug: parse returns a typed error (*json.SyntaxError), not the sentinel ErrParse. The two have different identities. errors.Is cannot find a match.
Fix: the caller should know which detection mechanism applies. If the returned error is typed, use errors.As:
If you really want sentinel-style matching, the package should wrap or align identities (custom Is method, or wrap the sentinel with %w).
Bug 16 — Multiple %w in pre-1.20 Go¶
Bug: Pre-Go-1.20, fmt.Errorf accepts at most one %w per format. With two, the second is treated as %v (in some versions) or produces a runtime error. errors.Is(result, err2) returns false.
Fix (Go 1.20+): multiple %w is allowed; the result implements Unwrap() []error. Fix (pre-1.20): use errors.Join:
Bug 17 — Sentinel as success value¶
var ErrOK = errors.New("ok") // BUG: invented
func write(p []byte) error {
if len(p) == 0 {
return ErrOK // BUG: not nil
}
// ...
}
Bug: ErrOK is non-nil. Any caller doing if err != nil { return err } will treat success as failure.
Fix: use nil for success. Always.
Bug 18 — Comparing two sentinel values¶
var ErrA = errors.New("a")
var ErrB = errors.New("a") // same message
func main() {
fmt.Println(ErrA == ErrB) // false; programmer expected true
}
Bug: Two errors.New calls produce two distinct *errorString pointers, regardless of the message. The programmer thought "same message → same value." Wrong.
Fix: alias if you really want one value:
Now ErrA == ErrB is true. (This is rarely what you want, by the way — usually you want two distinct sentinels.)
Bug 19 — Sentinel match inside errgroup shadowed by cancellation¶
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return ErrSpecific
})
g.Go(func() error {
<-ctx.Done()
return ctx.Err() // context.Canceled
})
if err := g.Wait(); err != nil {
if errors.Is(err, ErrSpecific) { /* expected */ }
}
Bug: errgroup returns the first non-nil error. If the second goroutine's ctx.Err() happens to come back faster (it shouldn't, but in race conditions it can), g.Wait() returns context.Canceled rather than ErrSpecific. The check fails.
Fix: make errors flow such that the important one is the one returned, or collect all errors via errors.Join if every one matters:
var (
mu sync.Mutex
errs []error
)
// each goroutine appends its err under mu, then errors.Join at the end
Bug 20 — Sentinel passed by value to a comparison¶
type errBox struct{ e error }
var ErrFoo = errors.New("foo")
func main() {
box := errBox{e: ErrFoo}
if box.e == ErrFoo {
fmt.Println("yes")
}
box2 := errBox{e: fmt.Errorf("ctx: %w", ErrFoo)}
if box2.e == ErrFoo {
fmt.Println("yes2") // BUG: never fires
}
}
Bug: Same trap as Bug 1, but easy to miss when the error is buried inside a struct. The first check works because box.e is the bare sentinel. The second fails because box2.e is a wrapped error.
Fix:
The rule: never use == for error comparison. Use errors.Is. Period.