35. Wrapping: adding context without losing information
An error that says not found is useless three layers up. An error that says
handling GET /users/7: loading profile: querying users table: not found
tells you exactly what happened. Getting from the first to the second is
called wrapping, and doing it well is a real skill.
%w versus %v
Both add context. Only one keeps the original error findable:
package main
import (
"errors"
"fmt"
)
var ErrDisk = errors.New("disk failure")
func wrapped() error {
return fmt.Errorf("saving file: %w", ErrDisk)
}
func flattened() error {
return fmt.Errorf("saving file: %v", ErrDisk)
}
func main() {
a, b := wrapped(), flattened()
fmt.Println(a)
fmt.Println(b)
fmt.Println("wrapped -> errors.Is:", errors.Is(a, ErrDisk))
fmt.Println("flattened -> errors.Is:", errors.Is(b, ErrDisk))
}
Identical messages, completely different behaviour. %v renders the error as
text and throws the value away; %w keeps a reference to it.
Default to %w. Use %v deliberately, when you want to hide the
underlying error — for instance so callers can't start depending on a
database driver's error type that you might swap out later.
Where to add context
The rule that keeps messages clean: each layer adds only what it knows that its callee doesn't.
package main
import (
"errors"
"fmt"
)
var ErrNoRows = errors.New("no rows in result set")
func queryUser(id int) error {
return ErrNoRows
}
func loadProfile(id int) error {
if err := queryUser(id); err != nil {
return fmt.Errorf("loading profile for user %d: %w", id, err)
}
return nil
}
func handleRequest(path string, id int) error {
if err := loadProfile(id); err != nil {
return fmt.Errorf("GET %s: %w", path, err)
}
return nil
}
func main() {
err := handleRequest("/profile", 7)
fmt.Println(err)
fmt.Println()
fmt.Println("is 'no rows'?", errors.Is(err, ErrNoRows))
}
queryUser knows about rows. loadProfile knows the user id. handleRequest
knows the path. Nobody repeats what the layer below already said, and the
final message reads as a path from the request down to the cause.
Three habits that ruin error messages
package main
import (
"errors"
"fmt"
)
var ErrDisk = errors.New("disk failure")
func main() {
base := ErrDisk
stutter := fmt.Errorf("failed to save file: error: %w", base)
redundant := fmt.Errorf("saving file failed with error while saving: %w", base)
vague := fmt.Errorf("operation failed: %w", base)
good := fmt.Errorf("saving report.pdf: %w", base)
for _, e := range []error{stutter, redundant, vague, good} {
fmt.Printf("processing job: %v\n", e)
}
}
Read the four output lines:
- Stutter — "failed to... error:" adds noise, not information.
- Redundant — says "saving" twice before you even reach the cause.
- Vague — "operation failed" tells you nothing you didn't know.
- Good — names the specific thing that was being done, and what to.
The test: could someone find this line in the codebase and know what the
program was doing? saving report.pdf passes. operation failed doesn't.
Don't wrap the same error twice
package main
import (
"errors"
"fmt"
)
var ErrTimeout = errors.New("timeout")
func doubleWrapped() error {
err := fmt.Errorf("calling api: %w", ErrTimeout)
return fmt.Errorf("calling api: %w", err)
}
func singleWrapped() error {
return fmt.Errorf("calling api: %w", ErrTimeout)
}
func main() {
fmt.Println(doubleWrapped())
fmt.Println(singleWrapped())
}
A layer that adds nothing should return the error unchanged:
if err := doThing(); err != nil {
return err // nothing useful to add — pass it straight up
}
Wrapping at every single frame produces messages like a: b: c: d: e: f:
connection refused, which is its own kind of unreadable. Wrap where a
meaningful boundary is crossed — entering a package, starting a named
operation, handling a request — not at every function.
Wrapping keeps working through several layers
package main
import (
"errors"
"fmt"
)
var ErrConfig = errors.New("bad configuration")
func main() {
err := error(ErrConfig)
for _, layer := range []string{"parsing yaml", "loading settings", "starting server"} {
err = fmt.Errorf("%s: %w", layer, err)
}
fmt.Println(err)
fmt.Println("still findable:", errors.Is(err, ErrConfig))
fmt.Println("\npeeling the chain:")
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Println(" -", e)
}
}
That loop at the bottom is a nice way to see the chain: each Unwrap drops
one layer of context until only the root cause is left. errors.Is does the
same walk, comparing as it goes.
Adding context with defer
When a function has many exit points, wrapping every return gets tedious.
A deferred closure over a named result does it once:
package main
import (
"errors"
"fmt"
)
var ErrNoRows = errors.New("no rows")
func find(id int) error {
if id == 0 {
return ErrNoRows
}
if id < 0 {
return errors.New("negative id")
}
return nil
}
func lookup(id int) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("lookup %d: %w", id, err)
}
}()
return find(id)
}
func main() {
fmt.Println(lookup(0))
fmt.Println(lookup(-1))
fmt.Println(lookup(5))
}
This is the one place a named result genuinely earns its keep. Because
err is a named variable, the deferred closure can read it and reassign
it after the return has chosen a value — so every failing path gets the
same context, added in exactly one place.
That's also the closure-versus-argument distinction from module 3 doing real
work: defer fmt.Errorf(...) would capture the value too early. The closure
reads err at the moment it runs.
Your turn
Add context at both layers so the final message reads exactly as shown, and
errors.Is still finds the sentinel:
processing order 42: charging card: payment declined
true
package main
import (
"errors"
"fmt"
)
var ErrDeclined = errors.New("payment declined")
func charge(amount int) error {
// wrap ErrDeclined with the context "charging card"
}
func processOrder(id, amount int) error {
// call charge and wrap its error with "processing order <id>"
}
func main() {
err := processOrder(42, 100)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrDeclined))
}
package main
import (
"errors"
"fmt"
)
var ErrDeclined = errors.New("payment declined")
func charge(amount int) error {
return fmt.Errorf("charging card: %w", ErrDeclined)
}
func processOrder(id, amount int) error {
if err := charge(amount); err != nil {
return fmt.Errorf("processing order %d: %w", id, err)
}
return nil
}
func main() {
err := processOrder(42, 100)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrDeclined))
}
Next: errors that carry data.