Wrapping Go errors: where, and mostly why

Mid-review on an order confirmation mailer, I confidently explained that the right call is to wrap an error once, at the package boundary, not at every internal step. The functions that build the message stay silent, only the public function that sends the email adds useful context. I even quoted Dave Cheney to back it up. Except while researching this article, I found out Dave Cheney changed his own mind since then, and the real rule isn't about where you wrap, it's about what you choose to expose.

The advice I just gave

The code in question looked like this: two internal functions that build an email's content, and two public functions that call them before sending:

func buildOrderMessage(order Order) (Message, error) {
    tpl, err := loadTemplate("order-confirm")
    if err != nil {
        return Message{}, err // no wrap, propagate as-is
    }
    return tpl.Render(order)
}

func mailOrder(order Order) error {
    msg, err := buildOrderMessage(order)
    if err != nil {
        return fmt.Errorf("failed to build message for order %d: %w", order.ID, err)
    }
    return sender.Send(msg)
}

The idea: wrapping at every layer produces a noisy message like failed to send: failed to build message: failed to load template: open failed. Nobody reads those intermediate layers in production, only the final message and the root cause matter. Wrapping once, at the right spot, keeps the message readable and adds the context that actually carries value, here the order ID. That's a correct argument. It's just not the whole picture.

Except Dave Cheney changed his mind

In 2016, Dave Cheney published Don't just check errors, handle them gracefully and popularized pkg/errors. His recommendation at the time: wrap at every layer with errors.Wrap, specifically to build a readable stack of context without a traditional stack trace:

func ReadFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, errors.Wrap(err, "open failed")
    }
    // ...
}

func ReadConfig() ([]byte, error) {
    config, err := ReadFile(path)
    return config, errors.Wrap(err, "could not read config")
}

The final message becomes could not read config: open failed: open /path: no such file or directory. Back then, the argument held: without wrapping at every level, a failure inside authenticate() bubbles up to the top of the stack showing just "no such file or directory," with no clue where or why.

Then in 2021, while looking for a maintainer to take over pkg/errors, Cheney wrote that he no longer wraps his errors at all. His reason: structured logging (slog fields rather than a concatenated string) carries that debug context better than nested layers of Wrap. The creator of the pattern ended up finding it redundant with modern tooling.

What matters here isn't picking a side between "always wrap" and "never wrap." It's understanding why both positions are defensible depending on what you're actually trying to solve: a readable production error message, or an API contract between packages.

The real question: what does %w commit you to

Since Go 1.13, %w in fmt.Errorf does more than decorate a message. It makes the inner error inspectable via errors.Is and errors.As from any caller. That means %w isn't a style choice, it's an API commitment:

// %w: exposing the inner error, callers can match on it
func LookupUser(id string) (*User, error) {
    row, err := db.QueryRow(...)
    if err != nil {
        return nil, fmt.Errorf("lookup user %s: %w", id, err)
    }
    // ...
}

// The caller can now do this, and rely on it staying true over time
if errors.Is(err, sql.ErrNoRows) {
    return http.StatusNotFound
}

The classic trap: LookupUser runs on database/sql today. The day it switches to an ORM, or a cache gets added in front of the query, sql.ErrNoRows stops bubbling up. The caller's errors.Is check silently breaks, no compile error, just a 404 that quietly becomes a 500 in production. The %w had turned an implementation detail (we use database/sql) into a public promise.

Which gives a simple rule straight from the Go 1.13 docs: default to %v, because it preserves the abstraction and exposes nothing. Reach for %w only when you consciously decide the caller needs to inspect the inner error, and in that case define your own sentinel rather than leaking a dependency's:

var ErrUserNotFound = errors.New("user not found")

func LookupUser(id string) (*User, error) {
    row, err := db.QueryRow(...)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("lookup user %s: %w", id, ErrUserNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("lookup user %s: %v", id, err) // internal detail, not exposed
    }
    // ...
}

The caller now matches on ErrUserNotFound, a sentinel the package owns. If database/sql disappears tomorrow, the contract still holds.

The public/private rule that actually holds up

Which leaves the "where" question. The most useful rule I found while digging comes from Efe Karakus's blog: wrap only at public boundaries, let private functions propagate as-is. His example makes the point well: a file path repeated three times in the error message, because every private layer kept adding its own redundant context.

// ❌ Every level wraps, even private functions
// "failed to read config from /etc/x: failed to read file from /etc/x: failed to open /etc/x: ..."

// ✅ Only public functions wrap
func readFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err // private, propagate as-is
    }
    return io.ReadAll(f)
}

func Read(path string) ([]byte, error) { // public, wraps once
    data, err := readFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to read config from %s: %w", path, err)
    }
    return data, nil
}

That's exactly what the order confirmation mailer was already doing, and that's why the initial advice wasn't wrong. It was just incomplete: it answered "where," not "%w or %v."

What I'm changing in the mailer

Revised version, applying both criteria together: where to wrap (the public boundary), and what to expose (only when the caller needs to act on it):

func buildOrderMessage(order Order) (Message, error) {
    tpl, err := loadTemplate("order-confirm")
    if err != nil {
        return Message{}, err // private, propagate as-is
    }
    return tpl.Render(order)
}

func mailOrder(order Order) error {
    msg, err := buildOrderMessage(order)
    if err != nil {
        // %v: the caller has nothing to match on for a template error,
        // it just needs to know which order failed
        return fmt.Errorf("failed to build message for order %d: %v", order.ID, err)
    }
    if err := sender.Send(msg); err != nil {
        if errors.Is(err, smtp.ErrRateLimited) {
            // %w here: the caller MUST be able to tell "retry later"
            // apart from a permanent send failure
            return fmt.Errorf("send order %d: %w", order.ID, smtp.ErrRateLimited)
        }
        return fmt.Errorf("send order %d: %v", order.ID, err)
    }
    return nil
}

The difference comes down to one line: the SMTP rate limit is wrapped with %w because the caller has a genuinely different action to take (retry with backoff). Everything else goes through %v, useful context for a human reading logs, without promising anyone that *template.Error will still be exposed ten versions from now.

Conclusion

The real trap isn't picking the wrong side between "wrap everywhere" and "wrap once." It's treating %w as a cosmetic message flourish when it's actually a contract clause. Once you ask "am I committing my package to always return this specific error," the position in the code becomes secondary. It also taught me to verify a quote before dropping it in a code review, even when it comes from a name as solid as Dave Cheney's.

Comments (0)