Wrapping Errors with %w in Go
How to add context to an error with fmt.Errorf and %w, the difference between %w and %v, and how errors.Unwrap walks the chain.
An error that travels up through several functions often arrives with too little information. open app.yaml: no such file or directory tells you a file is missing, but not which part of your program wanted it or why. Wrapping fixes this. Each function adds a short note about what it was doing, and the original error stays inside, untouched.
What Wrapping an Error Means
Wrapping means creating a new error that contains an older one. The new error has a longer message, and it remembers the error it came from. In Go you wrap with fmt.Errorf and the %w verb:
package main
import (
"fmt"
"os"
)
func readSettings() error {
_, err := os.ReadFile("settings.json")
if err != nil {
return fmt.Errorf("read settings: %w", err)
}
return nil
}
func startApp() error {
if err := readSettings(); err != nil {
return fmt.Errorf("start app: %w", err)
}
return nil
}
func main() {
if err := startApp(); err != nil {
fmt.Println(err)
}
}
Output:
start app: read settings: open settings.json: no such file or directory
Read the message from left to right and it describes the path the failure took: startApp called readSettings, which tried to open the file. You can trace the problem without a stack trace.
Think of it like putting a letter into envelopes. The original error is the letter. Each %w puts it into a new envelope with a note written on the outside. Nothing is lost, and you can always open the envelopes to get back to the letter.
%w Versus %v
%v also prints the error inside a message, and the printed text is exactly the same:
fmt.Errorf("read settings: %w", err) // wraps: original is kept
fmt.Errorf("read settings: %v", err) // formats: only the text is kept
The difference is invisible when printed but matters to code. With %w, the new error keeps a link to err. With %v, err is turned into text and the link is gone. Functions like errors.Is follow those links, so they only work through %w.
This program shows the difference:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
_, err := os.Open("nope.txt")
wrapped := fmt.Errorf("load: %w", err)
formatted := fmt.Errorf("load: %v", err)
fmt.Println(wrapped)
fmt.Println(formatted)
fmt.Println("wrapped is not-exist: ", errors.Is(wrapped, fs.ErrNotExist))
fmt.Println("formatted is not-exist:", errors.Is(formatted, fs.ErrNotExist))
}
Output:
load: open nope.txt: no such file or directory
load: open nope.txt: no such file or directory
wrapped is not-exist: true
formatted is not-exist: false
Both lines look identical in a log, yet only the wrapped one can still be recognized as "file does not exist".
The %v bug you cannot see in logs:
Using %v where you meant %w produces messages that look perfect, while every errors.Is check above it quietly returns false. Your "if file is missing, use defaults" branch never runs. When passing on an error, default to %w.
When to Use %v on Purpose
Wrapping makes the inner error part of what your function promises. If your package wraps a database driver error, callers may start checking for that driver's errors, and you can no longer switch drivers without breaking them.
When you want to hide the inner error on purpose, use %v. A typical case is a package boundary where the low-level detail is an implementation choice:
if err != nil {
return fmt.Errorf("save order %d: %v", id, err) // detail in the text only
}
This is a deliberate choice, not the default. Most code inside one application should use %w.
Getting the Inner Error With errors.Unwrap
A wrapped error has a hidden Unwrap() error method that returns the error inside it. errors.Unwrap calls it for you:
package main
import (
"errors"
"fmt"
)
func main() {
base := errors.New("disk full")
one := fmt.Errorf("write log: %w", base)
two := fmt.Errorf("handle request: %w", one)
fmt.Println(two)
fmt.Println(errors.Unwrap(two))
fmt.Println(errors.Unwrap(errors.Unwrap(two)))
fmt.Println(errors.Unwrap(base))
}
Output:
handle request: write log: disk full
write log: disk full
disk full
<nil>
Each call peels off one layer. When there is nothing left inside, errors.Unwrap returns nil. This series of errors inside errors is called the error chain.
In practice you rarely call errors.Unwrap yourself. errors.Is and errors.As walk the whole chain for you, which is safer than unwrapping a fixed number of times.
Making Your Own Error Type Wrappable
Custom error types can join the chain too. Give them a field for the inner error and an Unwrap method:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
type ConfigError struct {
Path string
Err error
}
func (e *ConfigError) Error() string {
return "config " + e.Path + ": " + e.Err.Error()
}
func (e *ConfigError) Unwrap() error { return e.Err }
func load(path string) error {
if _, err := os.ReadFile(path); err != nil {
return &ConfigError{Path: path, Err: err}
}
return nil
}
func main() {
err := load("app.toml")
fmt.Println(err)
fmt.Println("missing file:", errors.Is(err, fs.ErrNotExist))
}
Output:
config app.toml: open app.toml: no such file or directory
missing file: true
Because ConfigError has Unwrap, errors.Is can look through it and find the "not exist" error from the operating system.
How Much Context to Add
Wrap when your function knows something useful that the inner error does not: which user, which file, which step. If you have nothing to add, return the error as it is.
// adds nothing, just noise
return fmt.Errorf("error: %w", err)
// adds the step and the input
return fmt.Errorf("charge card for order %d: %w", orderID, err)
Do not repeat the inner message:
The standard library already puts the operation and path into its errors, for example open settings.json: .... Writing fmt.Errorf("open settings.json: %w", err) gives you open settings.json: open settings.json: no such file or directory. Describe your step, not theirs.
A healthy error chain:
If a failure prints as one line that reads like a path through your program, such as start app: read settings: open settings.json: no such file or directory, your wrapping is doing its job.
Summary
Wrap an error with fmt.Errorf("what I was doing: %w", err) to add context while keeping the original reachable. %w and %v print the same text, but only %w keeps the link that errors.Is, errors.As and errors.Unwrap follow. Use %v only when you deliberately want to hide the inner error. Custom error types join the chain by adding an Unwrap() error method, and each layer should add new information instead of repeating what is already there.