35. Wrapping: adding context without losing information

📖 Reading · 11 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

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:

  1. Stutter — "failed to... error:" adds noise, not information.
  2. Redundant — says "saving" twice before you even reach the cause.
  3. Vague — "operation failed" tells you nothing you didn't know.
  4. 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.