JavaRush /Courses /Go SELF /Error wrapping with %w and the cause chain

Error wrapping with %w and the cause chain

Go SELF
Level 17, Lesson 0
Available

1. Wrapping in fmt.Errorf

If you’re just starting out, it may seem like it’s enough to return fmt.Errorf("something went wrong"), and you’re done. But real code quickly becomes multi-layered: main calls run, which calls parse, which calls strconv.Atoi, and somewhere inside everything blows up. In the end you see an error, but you don’t understand which exact operation failed and what the root cause was.

Imagine an error as a package moving up through a chain of functions. Each function is like a sorting hub. It wants to stick its own label on the box: “right now I was parsing”, “right now I was loading config”, “right now I was validating input”. This is how context appears. But if you stick the labels the wrong way, you can lose information about what’s inside the box.

In Go 1.13 (and, of course, in Go 1.25) there is a standard mechanism for this: wrapping — wrapping an error so that it remains accessible “inside” and can be extracted using standard tools. This is done via fmt.Errorf and the special placeholder %w.

%v: context added, but the cause is hidden in the text

When we write:

return fmt.Errorf("parse age: %v", err)

we get a nice string, and for a human this is already useful. But technically this is a new error that merely contains the text of the old error. If later someone wants to reach the cause programmatically, there’s nothing “inside” anymore—only a string.

It’s important to understand this as a mechanism: formatting via %v does not create an unwrap-able cause chain. Before %w appeared, the result of fmt.Errorf with %v could not be “unwrapped” — errors.Unwrap returns nil, meaning the original error is not available as a structure.

Let’s look at a micro-example: we’ll add context, and then try to “peel off one layer”.

package main

import (
	"errors"
	"fmt"
)

func main() {
	base := errors.New("bad input")
	w := fmt.Errorf("parse: %v", base)

	fmt.Println(w)                 // parse: bad input
	fmt.Println(errors.Unwrap(w))  // <nil>
}

The comment here is simple: "parse: bad input" looks fine, but from the program’s point of view this is one flat error, without a reference to base. That’s why %v is “context for a human”, but not “context that preserves the cause”.

%w: same text, but an internal cause appears

Now for the interesting part: %w outwardly behaves almost the same as %v (the text will be similar), but inside it creates a wrapper that can return the root cause via the Unwrap method.

Formally: if %w is present in fmt.Errorf, then the returned error gets Unwrap() error, which returns the original argument error.

Let’s look at the same example, but with %w:

package main

import (
	"errors"
	"fmt"
)

func main() {
	base := errors.New("bad input")
	w := fmt.Errorf("parse: %w", base)

	fmt.Println(w)                // parse: bad input
	fmt.Println(errors.Unwrap(w)) // bad input
}

Key idea: we added context (“parse”) and did not lose the cause (“bad input”). That is wrapping.

It’s important to think of wrapping as creating a new error that “contains” the old one. It’s like nested dolls: the outer one says “parse”, and inside is the original error. You can open one layer and see what’s inside.

3. The cause chain and errors.Unwrap

Multiple wrappers in a row

In a real application, an error rarely passes through exactly one function. Usually it bubbles up through multiple levels, and each level adds its own context.

Go calls the sequence “error → its cause → cause of the cause → …” a cause chain. Repeated “unwrap” (removing one layer) lets you walk down this chain step by step.

Let’s assemble a chain of three levels:

package main

import (
	"errors"
	"fmt"
)

func main() {
	base := errors.New("bad input")
	w1 := fmt.Errorf("parse: %w", base)
	w2 := fmt.Errorf("run: %w", w1)

	fmt.Println(w2)                // run: parse: bad input
	fmt.Println(errors.Unwrap(w2)) // parse: bad input
}

Notice that errors.Unwrap(w2) removes only one layer. This is expected and correct: unwrap means “open one layer of the nested doll”, not “take everything apart down to the core”.

To make it easier to keep in mind, you can imagine a diagram like this:

flowchart TD
    A["base: bad input"] --> B["w1: parse: %w(base)"]
    B --> C["w2: run: %w(w1)"]

How to peel layers manually

In application code, people usually don’t want to walk the chain by hand, but understanding the mechanism is very useful: it helps you read other people’s errors, log them carefully, and not break function contracts.

In the standard library there is a function errors.Unwrap(err), which returns the result of err.Unwrap(), and if unwrap is not supported, it returns nil.

Let’s write a mini-function that prints the cause chain line by line. This is useful as a debugging tool (and yes, it’s the same “printf-debugging”, just with respect for structure).

package main

import (
	"errors"
	"fmt"
)

func printChain(err error) {
	for err != nil {
		fmt.Println("-", err)
		err = errors.Unwrap(err)
	}
}

And now let’s try it:

package main

import (
	"errors"
	"fmt"
)

func main() {
	base := errors.New("bad input")
	err := fmt.Errorf("parse: %w", base)
	err = fmt.Errorf("run: %w", err)

	printChain(err)
	// - run: parse: bad input
	// - parse: bad input
	// - bad input
}

This output sometimes looks like “I found an error and decided to read you the entire family tree back to your great-great-grandfather.” But for a developer it’s gold: you can see where context was added and what the root cause was.

4. Practice: CLI and good error messages

Application skeleton

Now we’ll continue our training storyline: a simple console application that accepts a command and an argument, validates input, and performs an action. No structs and no databases—we’re still in the “beginner league”, but the errors will already be “adult”.

Let the app understand the command add <title>. We’ll read input as two words via fmt.Scan (yes, a title without spaces is a limitation, but it’s enough for today’s topic).

First, let’s make a basic “stub” with run():

package main

import (
	"fmt"
)

func run() error {
	var cmd, arg string
	fmt.Scan(&cmd, &arg)
	fmt.Println("cmd =", cmd, "arg =", arg) // cmd = add arg = test
	return nil
}

func main() {
	_ = run()
}

Command validation and wrapping the cause

Now let’s add command validation and errors. We want run() to return an error upward, and main() to print it as the final result. This is good style: “inside” we return errors, “outside” we decide what to show the user.

Let’s make a function parseCommand:

package main

import (
	"errors"
	"fmt"
)

var ErrBadInput = errors.New("bad input")

func parseCommand(cmd string) error {
	if cmd != "add" {
		return fmt.Errorf("unknown command %q: %w", cmd, ErrBadInput)
	}
	return nil
}

Note: we return context (“unknown command ...”) and wrap ErrBadInput via %w. This means the cause "bad input" is preserved inside.

Wire it into run():

package main

import "fmt"

func run() error {
	var cmd, arg string
	fmt.Scan(&cmd, &arg)

	if err := parseCommand(cmd); err != nil {
		return fmt.Errorf("run: %w", err)
	}

	_ = arg
	return nil
}

And finally, main that prints the error:

package main

import "fmt"

func main() {
	if err := run(); err != nil {
		fmt.Println("ERROR:", err) // ERROR: run: unknown command "list": bad input
	}
}

It looks simple, but inside we already have a chain: run → parseCommand → ErrBadInput.

%w vs %v: a brief comparison

When you’re starting out, it’s easy to mix them up: “they both print the error, why have two different ones?” The difference is not the beauty of the string, but whether we preserve the cause as a structure.

What we do Example What a person will see Can we peel the cause via errors.Unwrap
Added text, but hid the cause inside the string
fmt.Errorf("parse: %v", err)
parse: <err text> No (Unwrap will return nil)
Added text and preserved the cause
fmt.Errorf("parse: %w", err)
Almost the same Yes (Unwrap will return err)

And here’s an important engineering idea: the “wrap or not wrap” decision is a contract decision, not a logging decision. By wrapping, you make the internal cause accessible to calling code; by not wrapping, you hide details for the sake of abstraction.

How to write good context

Error context is not the place for a novel like “War and Peace: Chapter 1, in which I tried to parse a string.” Good context is a short answer to the question: what operation were we performing when everything broke.

For example, contexts like these usually help:

  • read input
  • parse age
  • validate command
  • save task

And contexts like these are almost useless:

  • error
  • something went wrong
  • failed

Let’s improve our CLI by adding argument validation. Let the task title not be empty (for fmt.Scan this almost never happens, but it’s useful as an example):

package main

import (
	"errors"
	"fmt"
)

var ErrBadInput = errors.New("bad input")

func parseTitle(arg string) (string, error) {
	if arg == "" {
		return "", fmt.Errorf("title: %w", ErrBadInput)
	}
	return arg, nil
}

Now, in run(), let’s add context for each step:

package main

import "fmt"

func run() error {
	var cmd, arg string
	fmt.Scan(&cmd, &arg)

	if err := parseCommand(cmd); err != nil {
		return fmt.Errorf("parse command: %w", err)
	}

	title, err := parseTitle(arg)
	if err != nil {
		return fmt.Errorf("parse title: %w", err)
	}

	fmt.Println("added:", title) // added: test
	return nil
}

It becomes verbose, but if something breaks, you can almost always understand: where exactly, at which step, and what the cause was.

When you should not use %w

It’s tempting to make a rule: “always use %w”. But Go intentionally does not turn this into a dogma: absolute rules like “always %w” are wrong, because sometimes you do not want to expose the internal cause.

Imagine your application has a layer that works with some library. If you start returning errors outward in a way that lets callers unwrap down to specific types/values from that library, you (in a sense) promise: “this library is part of my public behavior.” And then you want to replace the library—and suddenly you break external code that relied on a specific cause.

For our small CLI, you can feel it like this. Suppose somewhere inside we introduce a function that “loads config” (for now it’s just an imitation):

package main

import (
	"errors"
	"fmt"
)

func loadConfig() error {
	internal := errors.New("low-level config format error")
	return fmt.Errorf("load config: %v", internal) // do not expose internals
}

Here we deliberately use %v: we explained everything to the human ("load config ..."), but we do not hand the “cause as a structure” to the outside. This kind of decision can be useful at module boundaries, so that external code does not start depending on your internal details.

5. Typical mistakes when wrapping with %w

Mistake #1: use %v and expect the cause to be preserved as a “structure”.
From the outside the string looks almost the same (“parse: bad input”), so a beginner sincerely thinks they did everything correctly. But %v just inserts text, and errors.Unwrap won’t be able to extract the original error. If you want a cause chain, use %w.

Mistake #2: write context that “says nothing”.
Phrases like fmt.Errorf("error: %w", err) add noise but add no meaning. Context should answer the question “what were we doing”: parse title, read input, load config. Then a single message is enough to understand where to look for the problem without opening the whole project.

Mistake #3: duplicate the cause in the context.
Sometimes you end up with something like fmt.Errorf("bad input: %w", ErrBadInput). It looks like “bad input: bad input”—as if the error got stuck on repeat. Context and cause should complement each other: context is about the operation, cause is about the type of problem.

Mistake #4: wrap nil and wonder why “there is no error”.
In Go, wrapping is often written inside if err != nil { ... }. This is not a ritual for beauty: if you mindlessly try to wrap an error without checking that it actually exists, you can get strange effects. The habit is simple: first check err != nil, then wrap.

Mistake #5: turn %w into “always and everywhere”, breaking abstraction boundaries.
Wrapping makes the cause available to external code. Sometimes that is exactly what you need, but sometimes you expose internal implementation details and accidentally turn them into part of the contract. The “wrap or not wrap” decision depends on whether you want the calling code to be able to inspect the cause.

1
Task
Go SELF, level 17, lesson 0
Locked
Registration form age
Registration form age
1
Task
Go SELF, level 17, lesson 0
Locked
Two wrapping modes
Two wrapping modes
1
Task
Go SELF, level 17, lesson 0
Locked
Cause chain
Cause chain
1
Task
Go SELF, level 17, lesson 0
Locked
Division in CLI
Division in CLI
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION