Go's fmt.Errorf %v vs %w Trap: The Error Wrapper That Doesn't Wrap

2026-06-30

This Go program loads user preferences from disk. If the file doesn't exist (first run), it should silently fall back to defaults. Any other error should be fatal. The function dutifully adds context to the error before returning it. The check at the call site uses errors.Is exactly as the standard library docs recommend. Yet every first-time user hits the fatal branch and sees a crash on launch.

package main

import (
    "errors"
    "fmt"
    "io/fs"
    "os"
)

func loadUserPrefs(path string) (map[string]string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loadUserPrefs(%q): %v", path, err)
    }
    return parsePrefs(data)
}

func main() {
    prefs, err := loadUserPrefs("/home/alice/.myapp/prefs")
    if err != nil {
        if errors.Is(err, fs.ErrNotExist) {
            prefs = defaultPrefs() // first run — silently use defaults
        } else {
            fmt.Println("fatal:", err)
            os.Exit(1)
        }
    }
    run(prefs)
}

The Bug

Look at the format verb: fmt.Errorf("...: %v", err). The %v verb calls err.Error() and splices the resulting string into a brand-new error. The original error object — the one wrapping fs.ErrNotExist down inside an *os.PathError — is discarded. What gets returned is an opaque error whose only relationship to the underlying cause is some text in its message.

When the caller runs errors.Is(err, fs.ErrNotExist), the function walks the error chain by repeatedly calling Unwrap(). The new error returned by fmt.Errorf with %v has no Unwrap() method, so the chain has length one — and that one element is not fs.ErrNotExist. The check returns false, control falls through to the else, and the user gets fatal: loadUserPrefs("..."): open ...: no such file or directory.

The whole point of Go 1.13's error wrapping was to make errors.Is and errors.As work across abstraction boundaries. The mechanism is opt-in: %w wraps (preserving the chain), %v formats (severing it). The two verbs print identically. The bug is invisible in logs, invisible in tests that just check err != nil, and only surfaces when someone tries to inspect which error happened.

The one-character fix:

return nil, fmt.Errorf("loadUserPrefs(%q): %w", path, err)
//                                            ^

Now fmt.Errorf returns a *fmt.wrapError with a working Unwrap(), errors.Is can climb the chain to fs.ErrNotExist, and first-run users get their defaults.

A few related landmines worth knowing:

Key Takeaway: fmt.Errorf with %v stringifies an error into a new opaque one and breaks the chain — always use %w when you want errors.Is and errors.As to keep working at the call site.

All newsletters