Wrapping and Inspecting Errors
Wrapping and Inspecting Errors
Wrapping adds context without erasing the original error. Inspecting walks that chain. The boring default is fmt.Errorf("verb: %w", err) on the way out, and errors.Is / errors.As on the way in. Do not parse Error() strings.
Mental model
%w(wrap) stores the inner error.%v/%sonly copy the string;errors.Iswill not see the inner value.errors.Unwrap(err)returns the next inner error, ornil.errors.Is(err, target)walks the chain (and joined errors) and reports whether any equalstarget(or implements anIsmethod).errors.As(err, &dst)walks the chain and stores the first matching type indst.errors.Join(e1, e2, ...)combines independent failures into one value.Is/Assee each of them.
Wrap when you add a layer (open ticket → charge card → print receipt). Join when several things failed at once (two tables both refused).
Worked examples
Case 1: %w keeps the sentinel reachable
The inner failure is ErrClosed. The outer message names the ticket. errors.Is still finds ErrClosed.
Save as wrap.go:
// wrap.go
package main
import (
"errors"
"fmt"
)
var ErrClosed = errors.New("desk closed")
func charge(open bool) error {
if !open {
return ErrClosed
}
return nil
}
func pay(ticket int, open bool) error {
if err := charge(open); err != nil {
return fmt.Errorf("pay ticket %d: %w", ticket, err)
}
return nil
}
func main() {
err := pay(41, false)
fmt.Println(err)
fmt.Println("closed:", errors.Is(err, ErrClosed))
fmt.Println("unwrap:", errors.Unwrap(err))
}Run:
go run wrap.goOutput:
pay ticket 41: desk closed
closed: true
unwrap: desk closed
The printed string is for logs. The Is check is for control flow. Keep both.
Case 2: %v looks the same and breaks Is
Same program, %v instead of %w. The log line is identical. The sentinel is gone.
Save as nowrap.go:
// nowrap.go
package main
import (
"errors"
"fmt"
)
var ErrClosed = errors.New("desk closed")
func pay(ticket int) error {
return fmt.Errorf("pay ticket %d: %v", ticket, ErrClosed)
}
func main() {
err := pay(41)
fmt.Println(err)
fmt.Println("closed:", errors.Is(err, ErrClosed))
fmt.Println("unwrap:", errors.Unwrap(err))
}Run:
go run nowrap.goOutput:
pay ticket 41: desk closed
closed: false
unwrap: <nil>
This is the bug that shows up a month later: a handler that is supposed to treat “desk closed” as non-fatal never fires. Use %w when the inner error is a real error value.
Case 3: errors.As for a dedicated type
Wrapping must not hide fields. As finds TableError through the outer fmt.Errorf.
Save as as.go:
// as.go
package main
import (
"errors"
"fmt"
)
type TableError struct {
Table int
Msg string
}
func (e TableError) Error() string {
return fmt.Sprintf("table %d: %s", e.Table, e.Msg)
}
func openTable(n int) error {
if n <= 0 {
return TableError{Table: n, Msg: "number must be positive"}
}
return nil
}
func seat(n int) error {
if err := openTable(n); err != nil {
return fmt.Errorf("seat: %w", err)
}
return nil
}
func main() {
err := seat(-2)
var te TableError
if errors.As(err, &te) {
fmt.Printf("cannot seat table %d (%s)\n", te.Table, te.Msg)
return
}
fmt.Println(err)
}Run:
go run as.goOutput:
cannot seat table -2 (number must be positive)
As needs a pointer to the destination. Pointer vs value must match what was returned (TableError here, not *TableError). If you return &TableError{...}, pass **TableError or use var te *TableError; errors.As(err, &te).
Case 4: errors.Join for independent failures
Closing two tables: both can fail. Returning the first error hides the second. Join them.
Save as join.go:
// join.go
package main
import (
"errors"
"fmt"
)
var (
ErrBusy = errors.New("busy")
ErrDirty = errors.New("dirty")
)
func closeTable(n int) error {
switch n {
case 3:
return fmt.Errorf("table %d: %w", n, ErrBusy)
case 7:
return fmt.Errorf("table %d: %w", n, ErrDirty)
default:
fmt.Printf("closed table %d\n", n)
return nil
}
}
func closeAll(tables []int) error {
var errs []error
for _, n := range tables {
if err := closeTable(n); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func main() {
err := closeAll([]int{3, 4, 7})
fmt.Println(err)
fmt.Println("busy:", errors.Is(err, ErrBusy))
fmt.Println("dirty:", errors.Is(err, ErrDirty))
}Run:
go run join.goOutput:
closed table 4
table 3: busy
table 7: dirty
busy: true
dirty: true
errors.Join skips nils. Joining a single error returns that error. Joining nothing returns nil. Is sees through the join.
The trap
String matching on err.Error(). A wrap changes the string. Your strings.Contains(err.Error(), "closed") misses pay ticket 41: desk closed if someone rewords the inner message — or matches the wrong error that happens to contain the word.
Save as by_string.go:
// by_string.go
package main
import (
"errors"
"fmt"
"strings"
)
var ErrClosed = errors.New("desk closed")
func main() {
err := fmt.Errorf("pay ticket %d: %w", 41, ErrClosed)
fmt.Println("contains closed:", strings.Contains(err.Error(), "closed"))
other := errors.New("enclosure door closed")
fmt.Println("false friend:", strings.Contains(other.Error(), "closed"))
fmt.Println("Is ErrClosed:", errors.Is(other, ErrClosed))
}Run:
go run by_string.goOutput:
contains closed: true
false friend: true
Is ErrClosed: false
errors.Is is the comparison. Strings are for logs.
The boring rule
- Wrap with
%wwhen the caller might inspect the inner error. fmt.Errorf("op: %w", err)— operation first, inner error last.errors.Isfor sentinels.errors.Asfor types with fields.errors.Joinwhen failures are independent. Do not Join a single causal chain; wrap that.- Do not compare
err.Error()inifstatements. - Pointer vs value: return one, inspect the same one.
Try this
- In
wrap.go, change%wto%v. ConfirmIsbecomes false. Change it back. - Return
*TableErrorfromopenTableinas.go. Fix theAsdestination so the program still printscannot seat table -2. - In
join.go, close only table4. Printerr == nil(it should be true).