Skip to content
gohacks.dev
Basics

Go Wrap Errors With percent w to Keep the Whole Chain

Wrap errors in Go with fmt.Errorf and %w to keep the chain, compare it with %v, use several %w verbs, walk the chain and decide when not to wrap.

6 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

An error with no context is nearly useless in a log. open /etc/app.conf: no such file or directory says what happened and nothing about what the program was trying to achieve. Adding that is wrapping: the function that failed returns its error to its caller, and the caller returns a new error that names what it was doing and holds the original inside. One verb makes it work, and one letter makes the difference between a chain you can inspect and a string you can only read.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article covers %w in practice. It belongs to the error handling guide, and the article on adding context without noise goes into how much to say at each layer. Everything ran on Go 1.25.6.

%w keeps the chain, %v does not

fmt.Errorf formats like Sprintf and returns an error. With %w the operand must be an error, and the result remembers it:

main.goGo
package main

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

func main() {
	_, err := os.Open("/nope/app.conf")

	wrapped := fmt.Errorf("load settings: %w", err)
	flattened := fmt.Errorf("load settings: %v", err)

	fmt.Println(wrapped)
	fmt.Println(flattened)

	fmt.Println("wrapped   Is ErrNotExist:", errors.Is(wrapped, fs.ErrNotExist))
	fmt.Println("flattened Is ErrNotExist:", errors.Is(flattened, fs.ErrNotExist))
	fmt.Println("unwrap works:", errors.Unwrap(wrapped) == err, errors.Unwrap(flattened) == nil)
}
Text
load settings: open /nope/app.conf: no such file or directory
load settings: open /nope/app.conf: no such file or directory
wrapped   Is ErrNotExist: true
flattened Is ErrNotExist: false
unwrap works: true true

Both errors print the same text, and only one of them is searchable. errors.Is(wrapped, fs.ErrNotExist) finds the sentinel three levels down, through a *fs.PathError that was itself wrapping a system call error. flattened is a string-only error with nothing beneath it, and errors.Unwrap returns nil. If you only ever print errors, the difference never shows, which is why the wrong verb survives code review and then breaks a errors.Is check in a different package months later.

Reading a chain

A chain is a linked list: each wrapper has an Unwrap method returning the next error. errors.Is and errors.As walk it for you, and you can walk it by hand, which is useful when you want to see the structure:

main.goGo
package main

import (
	"errors"
	"fmt"
	"os"
)

func main() {
	_, err := os.ReadFile("/nope")
	err = fmt.Errorf("read profile: %w", err)
	err = fmt.Errorf("handle request: %w", err)

	for e := err; e != nil; e = errors.Unwrap(e) {
		fmt.Printf("%T: %v\n", e, e)
	}
}
Text
*fmt.wrapError: handle request: read profile: open /nope: no such file or directory
*fmt.wrapError: read profile: open /nope: no such file or directory
*fs.PathError: open /nope: no such file or directory
syscall.Errno: no such file or directory

You get four levels: two *fmt.wrapError values from the two Errorf calls, the *fs.PathError the operating-system layer produced, and at the bottom the syscall.Errno, which is the number the kernel returned and the thing errors.Is(err, fs.ErrNotExist) matches in the end. Printing the types like this is the fastest way to understand an unfamiliar error: it shows which errors.As targets would match.

The order of the message follows the order of the calls, outermost first. That is why messages are written as verb noun: %w, so that the whole reads left to right from the highest-level action to the root cause: handle request: read profile: open /nope: no such file or directory. Keep each phrase short, lower case and free of "failed to" and "error", since the reader already knows they are reading an error.

More than one cause

Go 1.20 allowed several %w verbs in one format string. The result wraps all of them, and errors.Is and errors.As search every branch:

main.goGo
package main

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

func main() {
	err := fmt.Errorf("cleanup: %w; close: %w", fs.ErrPermission, io.ErrClosedPipe)
	fmt.Println(err)
	fmt.Println(errors.Is(err, fs.ErrPermission), errors.Is(err, io.ErrClosedPipe))
	fmt.Println(errors.Unwrap(err) == nil)
}
Text
cleanup: permission denied; close: io: read/write on closed pipe
true true
true

The wrapper in this case implements Unwrap() []error, so the single-error errors.Unwrap returns nil: it only follows the old form. This is the right tool for a cleanup path that has two independent failures to report, and for the general case of many errors errors.Join is the better fit (errors.Join).

Misuse and what vet says

Passing something that is not an error to %w does not panic. It produces a visible marker in the output:

main.goGo
package main

import "fmt"

func main() {
	fmt.Println(fmt.Errorf("lookup %w", "a string"))
	fmt.Println(fmt.Errorf("value: %w", nil))
}
Text
lookup %!w(string=a string)
value: %!w(<nil>)

The first prints lookup %!w(string=a string) and the second value: %!w(<nil>). Running go vet on the first reports fmt.Errorf format %w has arg "a string" of wrong type string, one of the quiet reasons to keep vet in CI. It does not flag the second, a nil operand, and that is the dangerous one in practice: a code path that forgot to check err != nil before wrapping creates a non-nil error from a nil one, and the program then reports a failure that did not happen. Always wrap inside the if err != nil block, and when a function ends with a wrap, read the line above it to confirm the guard is there. A code review that spots fmt.Errorf("...: %w", err) outside a conditional is worth the minute it takes. Tests help too: a test of the success path that asserts err == nil catches a wrap that fires when it should not.

Wrap, or choose not to

Wrapping is not free of consequences. Once a caller can reach the inner error, they can depend on it. If your function wraps an error from a database driver with %w, then code three layers up may write errors.As(err, &pgErr) against the driver's type, and you can no longer swap drivers without breaking it. The inner error became part of your API without anyone deciding that.

So decide. The default for code inside one module or one team is to wrap with %w, because the context is valuable and the coupling is internal. At a package boundary that you publish, or where an implementation detail should stay private, use %v and expose the behavior you want to support instead, either a sentinel or a custom type (error design compares the options). The Go blog's Working with Errors in Go 1.13 makes the same case: wrap when the caller may need to see the cause, and avoid it when the cause is an implementation detail.

A related choice is where to wrap. Each layer that adds information is a place to wrap, and a layer that adds nothing should return the error unchanged. return fmt.Errorf("process: %w", err) in a function whose name is process repeats what the stack already says. Wrap at boundaries where the caller does not know what you were doing, which means the calls that cross a package, a goroutine or a network. Between functions in one file, a bare return err is cleaner, and a message that repeats every function name quickly reads like a stutter: a: b: c: d: connection refused.

Where to go from here

Wrapping is half of the pair. The other half is asking questions of the chain, and errors.Is and errors.As covers when to use each. For the full documentation of what Unwrap, Is, As and Join guarantee, see the errors package, and for the formatting rules see fmt.Errorf.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.